| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "net/url" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "amadan.net/oficina/home/handoff" |
| 14 | "amadan.net/rastrillo/rastrillo/assertion" |
| 15 | rauth "amadan.net/rastrillo/rastrillo/auth" |
| 16 | assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| 17 | "amadan.net/rastrillo/rastrillo/csrf" |
| 18 | "amadan.net/rastrillo/rastrillo/migrate" |
| 19 | "amadan.net/rastrillo/rastrillo/sessions" |
| 20 | "github.com/carlosframework/docs/internal/docs/linkstore" |
| 21 | ) |
| 22 | |
| 23 | const bridgeOrigin = "https://blue.docs.oficina.works" |
| 24 | const bridgeIssuer = "https://id.oficina.works" |
| 25 | |
| 26 | type bridgeFixture struct { |
| 27 | b *homeBridge |
| 28 | app *App |
| 29 | key *assertioncrypto.Keypair |
| 30 | } |
| 31 | |
| 32 | func newBridgeFixture(t *testing.T) *bridgeFixture { |
| 33 | t.Helper() |
| 34 | app, err := NewForTestWith(t, Config{Origin: bridgeOrigin, SampleDocuments: "false"}) |
| 35 | if err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | schema := migrate.Merge(linkstore.Schema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff")) |
| 39 | if _, err := migrate.Apply(context.Background(), app.db, schema); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | key, err := assertioncrypto.Generate() |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | settings := &homeSettings{issuer: bridgeIssuer, consumerID: "blue-docs", keys: map[string][]byte{"key": key.SignPub()}, dek: bytes.Repeat([]byte{41}, 32)} |
| 47 | auth, err := rauth.New(rauth.Config{DB: app.db.Writer(), Origin: bridgeOrigin, InstanceKey: devInstanceKey, Logger: app.logger, ProofOff: true}) |
| 48 | if err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | b, err := newHomeBridge(settings, bridgeOrigin, app.db.Writer(), auth, app.handlers.render, app.store) |
| 52 | if err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | return &bridgeFixture{b: b, app: app, key: key} |
| 56 | } |
| 57 | func bridgeRequest(method, path string, cookies []*http.Cookie) *http.Request { |
| 58 | r := httptest.NewRequest(method, bridgeOrigin+path, nil) |
| 59 | for _, cookie := range cookies { |
| 60 | r.AddCookie(cookie) |
| 61 | } |
| 62 | return r |
| 63 | } |
| 64 | func bridgeCookie(t *testing.T, w *httptest.ResponseRecorder, name string) *http.Cookie { |
| 65 | t.Helper() |
| 66 | for _, cookie := range w.Result().Cookies() { |
| 67 | if cookie.Name == name && cookie.MaxAge >= 0 { |
| 68 | if !cookie.Secure || !cookie.HttpOnly || cookie.Domain != "" || cookie.Path != "/" || cookie.SameSite != http.SameSiteLaxMode { |
| 69 | t.Fatal("unsafe cookie attributes") |
| 70 | } |
| 71 | return cookie |
| 72 | } |
| 73 | } |
| 74 | t.Fatalf("missing cookie %s; status=%d", name, w.Code) |
| 75 | return nil |
| 76 | } |
| 77 | func bridgeNoSession(t *testing.T, b *homeBridge, w *httptest.ResponseRecorder) { |
| 78 | t.Helper() |
| 79 | for _, c := range w.Result().Cookies() { |
| 80 | if c.Name == b.sessionCookie { |
| 81 | t.Fatal("unexpected app session mutation") |
| 82 | } |
| 83 | } |
| 84 | } |
| 85 | func (f *bridgeFixture) local(t *testing.T, address, method string, authTime time.Time) *http.Cookie { |
| 86 | t.Helper() |
| 87 | if _, err := f.app.store.EnsurePerson(context.Background(), address); err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | token, err := f.b.sessions.Mint(sessions.Session{Subject: address, Method: method, AuthTime: authTime}) |
| 91 | if err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | return &http.Cookie{Name: f.b.sessionCookie, Value: token} |
| 95 | } |
| 96 | func (f *bridgeFixture) start(t *testing.T, cookies []*http.Cookie, path string) (*http.Cookie, string) { |
| 97 | t.Helper() |
| 98 | w := httptest.NewRecorder() |
| 99 | f.b.Start(w, bridgeRequest(http.MethodGet, "/auth/start?return_to="+url.QueryEscape(path), cookies)) |
| 100 | if w.Code != 303 { |
| 101 | t.Fatalf("start=%d body=%s", w.Code, w.Body.String()) |
| 102 | } |
| 103 | target, err := url.Parse(w.Header().Get("Location")) |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | return bridgeCookie(t, w, "__Host-oficina-assertion-pending"), target.Query().Get("nonce") |
| 108 | } |
| 109 | func (f *bridgeFixture) callback(t *testing.T, subject, nonce string, authTime time.Time) *http.Cookie { |
| 110 | t.Helper() |
| 111 | now := time.Now().Unix() |
| 112 | id, err := assertion.RandomID() |
| 113 | if err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: bridgeIssuer, Audience: bridgeOrigin, Subject: subject, KeyID: "key", ID: id, Nonce: nonce, IssuedAt: now, ExpiresAt: now + 90, AuthTime: authTime.Unix()}) |
| 117 | if err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | form := url.Values{"assertion": {token}} |
| 121 | r := httptest.NewRequest(http.MethodPost, bridgeOrigin+"/auth/assertion", strings.NewReader(form.Encode())) |
| 122 | r.Header.Set("Origin", bridgeIssuer) |
| 123 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 124 | w := httptest.NewRecorder() |
| 125 | f.b.Callback(w, r) |
| 126 | if w.Code != 303 { |
| 127 | t.Fatalf("callback=%d", w.Code) |
| 128 | } |
| 129 | bridgeNoSession(t, f.b, w) |
| 130 | return bridgeCookie(t, w, "__Host-oficina-assertion-inbox") |
| 131 | } |
| 132 | func (f *bridgeFixture) linkProof(t *testing.T, local *http.Cookie, subject string) (*http.Cookie, []*http.Cookie) { |
| 133 | t.Helper() |
| 134 | w := httptest.NewRecorder() |
| 135 | f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})) |
| 136 | if w.Code != 303 { |
| 137 | t.Fatalf("link start=%d", w.Code) |
| 138 | } |
| 139 | init := bridgeCookie(t, w, homeInitCookie) |
| 140 | cookies := []*http.Cookie{local, init} |
| 141 | start := httptest.NewRecorder() |
| 142 | f.b.Start(start, bridgeRequest(http.MethodGet, w.Header().Get("Location"), cookies)) |
| 143 | if start.Code != 303 { |
| 144 | t.Fatalf("fresh start=%d", start.Code) |
| 145 | } |
| 146 | target, _ := url.Parse(start.Header().Get("Location")) |
| 147 | if target.Query().Get("max_age") != "300" { |
| 148 | t.Fatal("link exchange did not request fresh authentication") |
| 149 | } |
| 150 | pending := bridgeCookie(t, start, "__Host-oficina-assertion-pending") |
| 151 | inbox := f.callback(t, subject, target.Query().Get("nonce"), time.Now().Add(-30*time.Second)) |
| 152 | finish := httptest.NewRecorder() |
| 153 | f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", append(cookies, pending, inbox))) |
| 154 | if finish.Code != 303 || finish.Header().Get("Location") != "/account/home/confirm" { |
| 155 | t.Fatalf("link finish=%d location=%q", finish.Code, finish.Header().Get("Location")) |
| 156 | } |
| 157 | bridgeNoSession(t, f.b, finish) |
| 158 | return bridgeCookie(t, finish, homeProofCookie), []*http.Cookie{local} |
| 159 | } |
| 160 | func (f *bridgeFixture) link(t *testing.T, local *http.Cookie, subject string) { |
| 161 | t.Helper() |
| 162 | proof, cookies := f.linkProof(t, local, subject) |
| 163 | cookies = append(cookies, proof) |
| 164 | page := httptest.NewRecorder() |
| 165 | f.b.ConfirmPage(page, bridgeRequest(http.MethodGet, "/account/home/confirm", cookies)) |
| 166 | if page.Code != 200 { |
| 167 | t.Fatalf("confirm page=%d", page.Code) |
| 168 | } |
| 169 | w := httptest.NewRecorder() |
| 170 | f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", cookies)) |
| 171 | if w.Code != 303 || w.Header().Get("Location") != "/" { |
| 172 | t.Fatalf("confirm=%d", w.Code) |
| 173 | } |
| 174 | bridgeNoSession(t, f.b, w) |
| 175 | } |
| 176 | |
| 177 | func TestHomeBridgeLinkThenOrdinarySignInPreservesIdentity(t *testing.T) { |
| 178 | f := newBridgeFixture(t) |
| 179 | local := f.local(t, "one@example.com", "magiclink", time.Time{}) |
| 180 | person, err := f.app.store.EnsurePerson(context.Background(), "one@example.com") |
| 181 | if err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | doc, err := f.app.store.Create(context.Background(), person, "Existing document") |
| 185 | if err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | f.link(t, local, "opaque-subject-one") |
| 189 | if _, ok := f.b.sessions.From(bridgeRequest(http.MethodGet, "/", []*http.Cookie{local})); !ok { |
| 190 | t.Fatal("linking replaced local session") |
| 191 | } |
| 192 | pending, nonce := f.start(t, nil, "/d/"+doc.Sqid) |
| 193 | original := time.Now().Add(-time.Hour).Truncate(time.Second) |
| 194 | inbox := f.callback(t, "opaque-subject-one", nonce, original) |
| 195 | finish := httptest.NewRecorder() |
| 196 | f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox})) |
| 197 | if finish.Code != 303 || finish.Header().Get("Location") != "/d/"+doc.Sqid { |
| 198 | t.Fatalf("ordinary finish=%d %q", finish.Code, finish.Header().Get("Location")) |
| 199 | } |
| 200 | cookie := bridgeCookie(t, finish, f.b.sessionCookie) |
| 201 | r := bridgeRequest(http.MethodGet, "/", []*http.Cookie{cookie}) |
| 202 | session, ok := f.b.sessions.From(r) |
| 203 | if !ok || session.Method != homeAssertionMethod || session.Subject != "opaque-subject-one" || !session.AuthTime.Equal(original) { |
| 204 | t.Fatalf("wrong Home session: %+v", session) |
| 205 | } |
| 206 | identity, err := f.b.resolver.resolve(sessions.WithSession(r, session)) |
| 207 | if err != nil || identity.Actor.PersonID != person || (identity.Actor.VerifiedAddress == nil || *identity.Actor.VerifiedAddress != "one@example.com") { |
| 208 | t.Fatalf("identity=%+v %v", identity, err) |
| 209 | } |
| 210 | if _, err := f.app.store.Get(context.Background(), person, doc.Sqid); err != nil { |
| 211 | t.Fatal("existing ownership changed", err) |
| 212 | } |
| 213 | var count int |
| 214 | if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM people").Scan(&count); err != nil || count != 1 { |
| 215 | t.Fatal("SSO created an extra person", count, err) |
| 216 | } |
| 217 | replay := httptest.NewRecorder() |
| 218 | f.b.Finish(replay, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox})) |
| 219 | if replay.Code == 303 { |
| 220 | t.Fatal("assertion replay minted session") |
| 221 | } |
| 222 | bridgeNoSession(t, f.b, replay) |
| 223 | } |
| 224 | |
| 225 | func TestHomeBridgeUnknownAndDifferentCurrentAccountNeverMint(t *testing.T) { |
| 226 | f := newBridgeFixture(t) |
| 227 | local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 228 | other := f.local(t, "two@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 229 | f.link(t, local, "linked-subject") |
| 230 | for _, tc := range []struct { |
| 231 | name, subject string |
| 232 | cookies []*http.Cookie |
| 233 | }{{"unknown", "unknown-private-subject", nil}, {"different-local", "linked-subject", []*http.Cookie{other}}} { |
| 234 | t.Run(tc.name, func(t *testing.T) { |
| 235 | pending, n := f.start(t, tc.cookies, "/") |
| 236 | inbox := f.callback(t, tc.subject, n, time.Now().Add(-time.Minute)) |
| 237 | w := httptest.NewRecorder() |
| 238 | f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", append(tc.cookies, pending, inbox))) |
| 239 | if w.Code != 403 { |
| 240 | t.Fatalf("rejected exchange=%d", w.Code) |
| 241 | } |
| 242 | bridgeNoSession(t, f.b, w) |
| 243 | if tc.name == "unknown" && !strings.Contains(w.Body.String(), "/signin/local") { |
| 244 | t.Fatal("unknown identity lacks local continuation") |
| 245 | } |
| 246 | if strings.Contains(w.Body.String(), tc.subject) { |
| 247 | t.Fatal("opaque identity leaked into response") |
| 248 | } |
| 249 | retry := httptest.NewRecorder() |
| 250 | f.b.Finish(retry, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox})) |
| 251 | if retry.Code == 303 { |
| 252 | t.Fatal("rejected completion remained reusable") |
| 253 | } |
| 254 | }) |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | func TestHomeBridgeChangedLocalSessionAndProofReplayRefused(t *testing.T) { |
| 259 | f := newBridgeFixture(t) |
| 260 | authTime := time.Now().Add(-time.Minute) |
| 261 | original := f.local(t, "one@example.com", "magiclink", authTime) |
| 262 | proof, cookies := f.linkProof(t, original, "opaque") |
| 263 | rotated := f.local(t, "one@example.com", "magiclink", authTime) |
| 264 | w := httptest.NewRecorder() |
| 265 | f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", []*http.Cookie{rotated, proof})) |
| 266 | if w.Code != 403 { |
| 267 | t.Fatal("different session linked proof", w.Code) |
| 268 | } |
| 269 | retry := httptest.NewRecorder() |
| 270 | f.b.ConfirmPost(retry, bridgeRequest(http.MethodPost, "/account/home/confirm", append(cookies, proof))) |
| 271 | if retry.Code != 403 { |
| 272 | t.Fatal("rejected proof reusable", retry.Code) |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | func TestHomeBridgeStrictCookiesAndStaleLocalRefusal(t *testing.T) { |
| 277 | f := newBridgeFixture(t) |
| 278 | local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 279 | for _, cookies := range [][]*http.Cookie{{{Name: homeInitCookie, Value: "bad"}}, {{Name: homeInitCookie, Value: strings.Repeat("A", 43)}, {Name: homeInitCookie, Value: strings.Repeat("A", 43)}}, {local, local}, {{Name: f.b.sessionCookie, Value: strings.Repeat("A", 43)}}} { |
| 280 | w := httptest.NewRecorder() |
| 281 | f.b.Start(w, bridgeRequest(http.MethodGet, "/auth/start", cookies)) |
| 282 | if w.Code == 303 { |
| 283 | t.Fatal("malformed/duplicate/unresolved cookie downgraded into ordinary sign-in") |
| 284 | } |
| 285 | } |
| 286 | for _, method := range []string{"magiclink", "keymail", homeAssertionMethod} { |
| 287 | cookie := f.local(t, "one@example.com", method, time.Now().Add(-6*time.Minute)) |
| 288 | w := httptest.NewRecorder() |
| 289 | f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{cookie})) |
| 290 | if w.Code != 403 { |
| 291 | t.Fatal("stale or Home method began linking", method, w.Code) |
| 292 | } |
| 293 | } |
| 294 | pending, n := f.start(t, nil, "/") |
| 295 | inbox := f.callback(t, "unknown", n, time.Now().Add(-time.Minute)) |
| 296 | for _, cookies := range [][]*http.Cookie{{pending, inbox, local, local}, {pending, inbox, {Name: f.b.sessionCookie, Value: strings.Repeat("A", 43)}}} { |
| 297 | w := httptest.NewRecorder() |
| 298 | f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", cookies)) |
| 299 | if w.Code != 403 { |
| 300 | t.Fatal("unresolved/ambiguous current account accepted", w.Code) |
| 301 | } |
| 302 | bridgeNoSession(t, f.b, w) |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | func TestHomeBridgeCallbackHasNoSessionDependencyAndExactOrigin(t *testing.T) { |
| 307 | f := newBridgeFixture(t) |
| 308 | _, n := f.start(t, nil, "/") |
| 309 | id, _ := assertion.RandomID() |
| 310 | now := time.Now().Unix() |
| 311 | token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: bridgeIssuer, Audience: bridgeOrigin, Subject: "opaque", KeyID: "key", ID: id, Nonce: n, IssuedAt: now, ExpiresAt: now + 90, AuthTime: now}) |
| 312 | if err != nil { |
| 313 | t.Fatal(err) |
| 314 | } |
| 315 | for _, origin := range []string{"", "https://evil.example", bridgeIssuer} { |
| 316 | r := httptest.NewRequest(http.MethodPost, bridgeOrigin+"/auth/assertion", strings.NewReader(url.Values{"assertion": {token}}.Encode())) |
| 317 | r.Header.Set("Origin", origin) |
| 318 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 319 | // Even malformed duplicate app cookies cannot affect the issuer callback. |
| 320 | r.AddCookie(&http.Cookie{Name: f.b.sessionCookie, Value: "bad"}) |
| 321 | r.AddCookie(&http.Cookie{Name: f.b.sessionCookie, Value: "bad"}) |
| 322 | w := httptest.NewRecorder() |
| 323 | f.b.Callback(w, r) |
| 324 | bridgeNoSession(t, f.b, w) |
| 325 | if origin == bridgeIssuer && w.Code != 303 { |
| 326 | t.Fatal("callback resolved local session", w.Code) |
| 327 | } |
| 328 | if origin != bridgeIssuer && w.Code == 303 { |
| 329 | t.Fatal("callback accepted different origin") |
| 330 | } |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | func TestHomeBridgeLinkMutationsRemainBehindCSRF(t *testing.T) { |
| 335 | f := newBridgeFixture(t) |
| 336 | local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 337 | protected := csrf.Protect(bridgeOrigin)(http.HandlerFunc(f.b.LinkStart)) |
| 338 | r := bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local}) |
| 339 | r.Header.Set("Origin", "https://evil.example") |
| 340 | r.Header.Set("Sec-Fetch-Site", "cross-site") |
| 341 | w := httptest.NewRecorder() |
| 342 | protected.ServeHTTP(w, r) |
| 343 | if w.Code != 403 { |
| 344 | t.Fatal("cross-site link initiation accepted", w.Code) |
| 345 | } |
| 346 | var count int |
| 347 | if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM docs_link_flows").Scan(&count); err != nil || count != 0 { |
| 348 | t.Fatal("cross-site initiation retained state", count, err) |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | func TestHomeBridgeOrdinaryProofCannotBecomeLinkProof(t *testing.T) { |
| 353 | f := newBridgeFixture(t) |
| 354 | local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 355 | w := httptest.NewRecorder() |
| 356 | f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})) |
| 357 | init := bridgeCookie(t, w, homeInitCookie) |
| 358 | // Bind an explicit link to its own generated nonce. |
| 359 | f.start(t, []*http.Cookie{local, init}, "/account/home/confirm") |
| 360 | // A separate ordinary sign-in produces a valid assertion with another nonce. |
| 361 | pending, n := f.start(t, []*http.Cookie{local}, "/") |
| 362 | inbox := f.callback(t, "opaque", n, time.Now().Add(-time.Minute)) |
| 363 | finish := httptest.NewRecorder() |
| 364 | f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{local, init, pending, inbox})) |
| 365 | if finish.Code != 403 { |
| 366 | t.Fatal("ordinary exchange reused for linking", finish.Code) |
| 367 | } |
| 368 | bridgeNoSession(t, f.b, finish) |
| 369 | for _, cookie := range finish.Result().Cookies() { |
| 370 | if cookie.Name == homeProofCookie && cookie.MaxAge > 0 { |
| 371 | t.Fatal("wrong nonce created proof") |
| 372 | } |
| 373 | } |
| 374 | var rows int |
| 375 | if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM docs_link_flows").Scan(&rows); err != nil || rows != 0 { |
| 376 | t.Fatal("failed link initiation not burned", rows, err) |
| 377 | } |
| 378 | } |
| 379 | |
| 380 | func TestHomeBridgeStaleFreshExchangeCannotMintOrCreateProof(t *testing.T) { |
| 381 | f := newBridgeFixture(t) |
| 382 | local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 383 | w := httptest.NewRecorder() |
| 384 | f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})) |
| 385 | init := bridgeCookie(t, w, homeInitCookie) |
| 386 | pending, n := f.start(t, []*http.Cookie{local, init}, "/account/home/confirm") |
| 387 | inbox := f.callback(t, "opaque", n, time.Now().Add(-6*time.Minute)) |
| 388 | for i := 0; i < 2; i++ { |
| 389 | finish := httptest.NewRecorder() |
| 390 | f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{local, init, pending, inbox})) |
| 391 | if finish.Code == 303 { |
| 392 | t.Fatal("stale Home proof accepted or revived") |
| 393 | } |
| 394 | bridgeNoSession(t, f.b, finish) |
| 395 | } |
| 396 | var rows int |
| 397 | if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM docs_link_flows WHERE stage = 'pending'").Scan(&rows); err != nil || rows != 0 { |
| 398 | t.Fatal("stale Home gesture created a pending link", rows, err) |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | func TestHomeBridgeCurrentHomeSubjectIsCheckedByConsumer(t *testing.T) { |
| 403 | f := newBridgeFixture(t) |
| 404 | local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 405 | f.link(t, local, "subject-one") |
| 406 | other := f.local(t, "two@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 407 | f.link(t, other, "subject-two") |
| 408 | homeToken, err := f.b.sessions.Mint(sessions.Session{Subject: "subject-one", Method: homeAssertionMethod, AuthTime: time.Now().Add(-time.Minute)}) |
| 409 | if err != nil { |
| 410 | t.Fatal(err) |
| 411 | } |
| 412 | current := &http.Cookie{Name: f.b.sessionCookie, Value: homeToken} |
| 413 | pending, n := f.start(t, []*http.Cookie{current}, "/") |
| 414 | inbox := f.callback(t, "subject-two", n, time.Now().Add(-time.Minute)) |
| 415 | w := httptest.NewRecorder() |
| 416 | f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{current, pending, inbox})) |
| 417 | if w.Code == 303 { |
| 418 | t.Fatal("signed-in Home identity silently replaced") |
| 419 | } |
| 420 | bridgeNoSession(t, f.b, w) |
| 421 | } |
| 422 | |
| 423 | func TestHomeBridgeReturnPathCallbackRemainsSafe(t *testing.T) { |
| 424 | for _, tc := range []struct{ input, want string }{{"/d/existing-document?view=reading", "/d/existing-document?view=reading"}, {"https://evil.example/private", "/"}, {"//evil.example", "/"}, {"/auth/finish", "/"}, {"/%2fhost", "/"}, {"", "/"}} { |
| 425 | t.Run(tc.input, func(t *testing.T) { |
| 426 | f := newBridgeFixture(t) |
| 427 | f.b.returnPath = func(*http.Request) string { return tc.input } |
| 428 | local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 429 | proof, cookies := f.linkProof(t, local, "opaque") |
| 430 | w := httptest.NewRecorder() |
| 431 | f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", append(cookies, proof))) |
| 432 | if w.Code != 303 || w.Header().Get("Location") != tc.want { |
| 433 | t.Fatalf("return location=%q status=%d", w.Header().Get("Location"), w.Code) |
| 434 | } |
| 435 | }) |
| 436 | } |
| 437 | } |
| 438 | |