| 1 | package connect |
| 2 | |
| 3 | import ( |
| 4 | "amadan.net/oficina/home/connect/linkstore" |
| 5 | "amadan.net/oficina/home/handoff" |
| 6 | "amadan.net/rastrillo/rastrillo/assertion" |
| 7 | assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| 8 | "bytes" |
| 9 | "context" |
| 10 | "crypto/rand" |
| 11 | "database/sql" |
| 12 | "encoding/base64" |
| 13 | "io/fs" |
| 14 | _ "modernc.org/sqlite" |
| 15 | "net/http" |
| 16 | "net/http/httptest" |
| 17 | "net/url" |
| 18 | "path/filepath" |
| 19 | "strings" |
| 20 | "testing" |
| 21 | "time" |
| 22 | ) |
| 23 | |
| 24 | const bridgeOrigin = "https://blue.docs.oficina.works" |
| 25 | const bridgeIssuer = "https://id.oficina.works" |
| 26 | |
| 27 | type fakeSessions struct { |
| 28 | cookie string |
| 29 | values map[string]Session |
| 30 | } |
| 31 | |
| 32 | func (f *fakeSessions) Mint(s Session) (string, error) { |
| 33 | raw := make([]byte, 32) |
| 34 | if _, err := rand.Read(raw); err != nil { |
| 35 | return "", err |
| 36 | } |
| 37 | token := base64.RawURLEncoding.EncodeToString(raw) |
| 38 | s.At = time.Now() |
| 39 | f.values[token] = s |
| 40 | return token, nil |
| 41 | } |
| 42 | func (f *fakeSessions) From(r *http.Request) (Session, bool) { |
| 43 | c, e := r.Cookie(f.cookie) |
| 44 | if e != nil { |
| 45 | return Session{}, false |
| 46 | } |
| 47 | s, ok := f.values[c.Value] |
| 48 | return s, ok |
| 49 | } |
| 50 | |
| 51 | type bridgeFixture struct { |
| 52 | b *Bridge |
| 53 | db *sql.DB |
| 54 | key *assertioncrypto.Keypair |
| 55 | sessions *fakeSessions |
| 56 | } |
| 57 | |
| 58 | func newBridgeFixture(t *testing.T) *bridgeFixture { |
| 59 | t.Helper() |
| 60 | d, e := sql.Open("sqlite", filepath.Join(t.TempDir(), "connect.db")+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)") |
| 61 | if e != nil { |
| 62 | t.Fatal(e) |
| 63 | } |
| 64 | t.Cleanup(func() { d.Close() }) |
| 65 | for _, schema := range []fs.FS{handoff.Migrations, linkstore.Migrations} { |
| 66 | files, e := fs.Glob(schema, "migrations/*.sql") |
| 67 | if e != nil { |
| 68 | t.Fatal(e) |
| 69 | } |
| 70 | for _, name := range files { |
| 71 | raw, e := fs.ReadFile(schema, name) |
| 72 | if e != nil { |
| 73 | t.Fatal(e) |
| 74 | } |
| 75 | if _, e = d.Exec(string(raw)); e != nil { |
| 76 | t.Fatal(e) |
| 77 | } |
| 78 | } |
| 79 | } |
| 80 | if _, e = d.Exec("CREATE TABLE people(id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE)"); e != nil { |
| 81 | t.Fatal(e) |
| 82 | } |
| 83 | key, e := assertioncrypto.Generate() |
| 84 | if e != nil { |
| 85 | t.Fatal(e) |
| 86 | } |
| 87 | sessions := &fakeSessions{cookie: "__Host-local-session", values: map[string]Session{}} |
| 88 | f := &bridgeFixture{db: d, key: key, sessions: sessions} |
| 89 | cfg := Config{DB: d, Origin: bridgeOrigin, Issuer: bridgeIssuer, ConsumerID: "blue-docs", Namespace: "oficina/docs", DEK: bytes.Repeat([]byte{41}, 32), Keys: map[string][]byte{"key": key.SignPub()}, SessionCookie: sessions.cookie, |
| 90 | ReadSession: func(r *http.Request) (*Session, error) { |
| 91 | s, ok := sessions.From(r) |
| 92 | if !ok { |
| 93 | return nil, nil |
| 94 | } |
| 95 | return &s, nil |
| 96 | }, |
| 97 | MintHomeSession: func(w http.ResponseWriter, r *http.Request, s Session) error { |
| 98 | token, e := sessions.Mint(s) |
| 99 | if e != nil { |
| 100 | return e |
| 101 | } |
| 102 | http.SetCookie(w, &http.Cookie{Name: sessions.cookie, Value: token, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) |
| 103 | return nil |
| 104 | }, |
| 105 | LookupLocalSession: func(ctx context.Context, s Session) (Person, error) { |
| 106 | var p Person |
| 107 | e := d.QueryRowContext(ctx, "SELECT id,email FROM people WHERE email=?", s.Subject).Scan(&p.ID, &p.Label) |
| 108 | return p, e |
| 109 | }, |
| 110 | LookupPerson: func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) { |
| 111 | var label string |
| 112 | e := q.QueryRowContext(ctx, "SELECT email FROM people WHERE id=? AND email<>''", id).Scan(&label) |
| 113 | return label, e |
| 114 | }, |
| 115 | Render: func(w http.ResponseWriter, r *http.Request, state, label string, status int) { |
| 116 | w.WriteHeader(status) |
| 117 | w.Write([]byte(state)) |
| 118 | }} |
| 119 | f.b, e = New(cfg) |
| 120 | if e != nil { |
| 121 | t.Fatal(e) |
| 122 | } |
| 123 | return f |
| 124 | } |
| 125 | func bridgeRequest(method, path string, cookies []*http.Cookie) *http.Request { |
| 126 | r := httptest.NewRequest(method, bridgeOrigin+path, nil) |
| 127 | for _, cookie := range cookies { |
| 128 | r.AddCookie(cookie) |
| 129 | } |
| 130 | return r |
| 131 | } |
| 132 | func bridgeCookie(t *testing.T, w *httptest.ResponseRecorder, name string) *http.Cookie { |
| 133 | t.Helper() |
| 134 | for _, cookie := range w.Result().Cookies() { |
| 135 | if cookie.Name == name && cookie.MaxAge >= 0 { |
| 136 | if !cookie.Secure || !cookie.HttpOnly || cookie.Domain != "" || cookie.Path != "/" || cookie.SameSite != http.SameSiteLaxMode { |
| 137 | t.Fatal("unsafe cookie attributes") |
| 138 | } |
| 139 | return cookie |
| 140 | } |
| 141 | } |
| 142 | t.Fatalf("missing cookie %s; status=%d", name, w.Code) |
| 143 | return nil |
| 144 | } |
| 145 | func bridgeNoSession(t *testing.T, b *Bridge, w *httptest.ResponseRecorder) { |
| 146 | t.Helper() |
| 147 | for _, c := range w.Result().Cookies() { |
| 148 | if c.Name == b.sessionCookie { |
| 149 | t.Fatal("unexpected app session mutation") |
| 150 | } |
| 151 | } |
| 152 | } |
| 153 | func (f *bridgeFixture) local(t *testing.T, address, method string, authTime time.Time) *http.Cookie { |
| 154 | t.Helper() |
| 155 | if _, err := f.db.Exec("INSERT INTO people(email) VALUES(?) ON CONFLICT DO NOTHING", address); err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | token, err := f.sessions.Mint(Session{Subject: address, Method: method, AuthTime: authTime}) |
| 159 | if err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | return &http.Cookie{Name: f.b.sessionCookie, Value: token} |
| 163 | } |
| 164 | func (f *bridgeFixture) start(t *testing.T, cookies []*http.Cookie, path string) (*http.Cookie, string) { |
| 165 | t.Helper() |
| 166 | w := httptest.NewRecorder() |
| 167 | f.b.Start(w, bridgeRequest(http.MethodGet, "/auth/start?return_to="+url.QueryEscape(path), cookies)) |
| 168 | if w.Code != 303 { |
| 169 | t.Fatalf("start=%d body=%s", w.Code, w.Body.String()) |
| 170 | } |
| 171 | target, err := url.Parse(w.Header().Get("Location")) |
| 172 | if err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | return bridgeCookie(t, w, "__Host-oficina-assertion-pending"), target.Query().Get("nonce") |
| 176 | } |
| 177 | func (f *bridgeFixture) callback(t *testing.T, subject, nonce string, authTime time.Time) *http.Cookie { |
| 178 | t.Helper() |
| 179 | now := time.Now().Unix() |
| 180 | id, err := assertion.RandomID() |
| 181 | if err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | 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()}) |
| 185 | if err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | form := url.Values{"assertion": {token}} |
| 189 | r := httptest.NewRequest(http.MethodPost, bridgeOrigin+"/auth/assertion", strings.NewReader(form.Encode())) |
| 190 | r.Header.Set("Origin", bridgeIssuer) |
| 191 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 192 | w := httptest.NewRecorder() |
| 193 | f.b.Callback(w, r) |
| 194 | if w.Code != 303 { |
| 195 | t.Fatalf("callback=%d", w.Code) |
| 196 | } |
| 197 | bridgeNoSession(t, f.b, w) |
| 198 | return bridgeCookie(t, w, "__Host-oficina-assertion-inbox") |
| 199 | } |
| 200 | func (f *bridgeFixture) linkProof(t *testing.T, local *http.Cookie, subject string) (*http.Cookie, []*http.Cookie) { |
| 201 | t.Helper() |
| 202 | w := httptest.NewRecorder() |
| 203 | f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})) |
| 204 | if w.Code != 303 { |
| 205 | t.Fatalf("link start=%d", w.Code) |
| 206 | } |
| 207 | init := bridgeCookie(t, w, homeInitCookie) |
| 208 | cookies := []*http.Cookie{local, init} |
| 209 | start := httptest.NewRecorder() |
| 210 | f.b.Start(start, bridgeRequest(http.MethodGet, w.Header().Get("Location"), cookies)) |
| 211 | if start.Code != 303 { |
| 212 | t.Fatalf("fresh start=%d", start.Code) |
| 213 | } |
| 214 | target, _ := url.Parse(start.Header().Get("Location")) |
| 215 | if target.Query().Get("max_age") != "300" { |
| 216 | t.Fatal("link exchange did not request fresh authentication") |
| 217 | } |
| 218 | pending := bridgeCookie(t, start, "__Host-oficina-assertion-pending") |
| 219 | inbox := f.callback(t, subject, target.Query().Get("nonce"), time.Now().Add(-30*time.Second)) |
| 220 | finish := httptest.NewRecorder() |
| 221 | f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", append(cookies, pending, inbox))) |
| 222 | if finish.Code != 303 || finish.Header().Get("Location") != "/account/home/confirm" { |
| 223 | t.Fatalf("link finish=%d location=%q", finish.Code, finish.Header().Get("Location")) |
| 224 | } |
| 225 | bridgeNoSession(t, f.b, finish) |
| 226 | return bridgeCookie(t, finish, homeProofCookie), []*http.Cookie{local} |
| 227 | } |
| 228 | func (f *bridgeFixture) link(t *testing.T, local *http.Cookie, subject string) { |
| 229 | t.Helper() |
| 230 | proof, cookies := f.linkProof(t, local, subject) |
| 231 | cookies = append(cookies, proof) |
| 232 | page := httptest.NewRecorder() |
| 233 | f.b.ConfirmPage(page, bridgeRequest(http.MethodGet, "/account/home/confirm", cookies)) |
| 234 | if page.Code != 200 { |
| 235 | t.Fatalf("confirm page=%d", page.Code) |
| 236 | } |
| 237 | w := httptest.NewRecorder() |
| 238 | f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", cookies)) |
| 239 | if w.Code != 303 || w.Header().Get("Location") != "/" { |
| 240 | t.Fatalf("confirm=%d", w.Code) |
| 241 | } |
| 242 | bridgeNoSession(t, f.b, w) |
| 243 | } |
| 244 | |
| 245 | func TestHomeBridgeLinkThenOrdinarySignInPreservesIdentity(t *testing.T) { |
| 246 | f := newBridgeFixture(t) |
| 247 | local := f.local(t, "one@example.com", "magiclink", time.Time{}) |
| 248 | var person int64 |
| 249 | if err := f.db.QueryRow("SELECT id FROM people WHERE email='one@example.com'").Scan(&person); err != nil { |
| 250 | t.Fatal(err) |
| 251 | } |
| 252 | if _, err := f.db.Exec("CREATE TABLE content(owner INTEGER, bytes BLOB); INSERT INTO content VALUES(1,x'010203')"); err != nil { |
| 253 | t.Fatal(err) |
| 254 | } |
| 255 | f.link(t, local, "opaque-subject-one") |
| 256 | if _, ok := f.sessions.From(bridgeRequest(http.MethodGet, "/", []*http.Cookie{local})); !ok { |
| 257 | t.Fatal("linking replaced local session") |
| 258 | } |
| 259 | pending, nonce := f.start(t, nil, "/d/existing") |
| 260 | original := time.Now().Add(-time.Hour).Truncate(time.Second) |
| 261 | inbox := f.callback(t, "opaque-subject-one", nonce, original) |
| 262 | finish := httptest.NewRecorder() |
| 263 | f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox})) |
| 264 | if finish.Code != 303 || finish.Header().Get("Location") != "/d/existing" { |
| 265 | t.Fatalf("ordinary finish=%d %q", finish.Code, finish.Header().Get("Location")) |
| 266 | } |
| 267 | cookie := bridgeCookie(t, finish, f.b.sessionCookie) |
| 268 | r := bridgeRequest(http.MethodGet, "/", []*http.Cookie{cookie}) |
| 269 | session, ok := f.sessions.From(r) |
| 270 | if !ok || session.Method != HomeMethod || session.Subject != "opaque-subject-one" || !session.AuthTime.Equal(original) { |
| 271 | t.Fatalf("wrong Home session: %+v", session) |
| 272 | } |
| 273 | identity, err := f.b.ResolveSession(r) |
| 274 | if err != nil || identity.ID != person || identity.Label != "one@example.com" { |
| 275 | t.Fatal("identity changed", err) |
| 276 | } |
| 277 | var content []byte |
| 278 | if err = f.db.QueryRow("SELECT bytes FROM content WHERE owner=?", person).Scan(&content); err != nil || !bytes.Equal(content, []byte{1, 2, 3}) { |
| 279 | t.Fatal("existing ownership changed", err) |
| 280 | } |
| 281 | var count int |
| 282 | if err := f.db.QueryRow("SELECT COUNT(*) FROM people").Scan(&count); err != nil || count != 1 { |
| 283 | t.Fatal("SSO created an extra person", count, err) |
| 284 | } |
| 285 | replay := httptest.NewRecorder() |
| 286 | f.b.Finish(replay, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox})) |
| 287 | if replay.Code == 303 { |
| 288 | t.Fatal("assertion replay minted session") |
| 289 | } |
| 290 | bridgeNoSession(t, f.b, replay) |
| 291 | } |
| 292 | |
| 293 | func TestHomeBridgeUnknownAndDifferentCurrentAccountNeverMint(t *testing.T) { |
| 294 | f := newBridgeFixture(t) |
| 295 | local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 296 | other := f.local(t, "two@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 297 | f.link(t, local, "linked-subject") |
| 298 | for _, tc := range []struct { |
| 299 | name, subject string |
| 300 | cookies []*http.Cookie |
| 301 | }{{"unknown", "unknown-private-subject", nil}, {"different-local", "linked-subject", []*http.Cookie{other}}} { |
| 302 | t.Run(tc.name, func(t *testing.T) { |
| 303 | pending, n := f.start(t, tc.cookies, "/") |
| 304 | inbox := f.callback(t, tc.subject, n, time.Now().Add(-time.Minute)) |
| 305 | w := httptest.NewRecorder() |
| 306 | f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", append(tc.cookies, pending, inbox))) |
| 307 | if w.Code != 403 { |
| 308 | t.Fatalf("rejected exchange=%d", w.Code) |
| 309 | } |
| 310 | bridgeNoSession(t, f.b, w) |
| 311 | if tc.name == "unknown" && w.Body.String() != "unknown" { |
| 312 | t.Fatal("unknown identity did not select local continuation state") |
| 313 | } |
| 314 | if strings.Contains(w.Body.String(), tc.subject) { |
| 315 | t.Fatal("opaque identity leaked into response") |
| 316 | } |
| 317 | retry := httptest.NewRecorder() |
| 318 | f.b.Finish(retry, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox})) |
| 319 | if retry.Code == 303 { |
| 320 | t.Fatal("rejected completion remained reusable") |
| 321 | } |
| 322 | }) |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | func TestHomeBridgeChangedLocalSessionAndProofReplayRefused(t *testing.T) { |
| 327 | f := newBridgeFixture(t) |
| 328 | authTime := time.Now().Add(-time.Minute) |
| 329 | original := f.local(t, "one@example.com", "magiclink", authTime) |
| 330 | proof, cookies := f.linkProof(t, original, "opaque") |
| 331 | rotated := f.local(t, "one@example.com", "magiclink", authTime) |
| 332 | w := httptest.NewRecorder() |
| 333 | f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", []*http.Cookie{rotated, proof})) |
| 334 | if w.Code != 403 { |
| 335 | t.Fatal("different session linked proof", w.Code) |
| 336 | } |
| 337 | retry := httptest.NewRecorder() |
| 338 | f.b.ConfirmPost(retry, bridgeRequest(http.MethodPost, "/account/home/confirm", append(cookies, proof))) |
| 339 | if retry.Code != 403 { |
| 340 | t.Fatal("rejected proof reusable", retry.Code) |
| 341 | } |
| 342 | } |
| 343 | |
| 344 | func TestHomeBridgeStrictCookiesAndStaleLocalRefusal(t *testing.T) { |
| 345 | f := newBridgeFixture(t) |
| 346 | local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 347 | 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)}}} { |
| 348 | w := httptest.NewRecorder() |
| 349 | f.b.Start(w, bridgeRequest(http.MethodGet, "/auth/start", cookies)) |
| 350 | if w.Code == 303 { |
| 351 | t.Fatal("malformed/duplicate/unresolved cookie downgraded into ordinary sign-in") |
| 352 | } |
| 353 | } |
| 354 | for _, method := range []string{"magiclink", "keymail", HomeMethod} { |
| 355 | cookie := f.local(t, "one@example.com", method, time.Now().Add(-6*time.Minute)) |
| 356 | w := httptest.NewRecorder() |
| 357 | f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{cookie})) |
| 358 | if w.Code != 403 { |
| 359 | t.Fatal("stale or Home method began linking", method, w.Code) |
| 360 | } |
| 361 | } |
| 362 | pending, n := f.start(t, nil, "/") |
| 363 | inbox := f.callback(t, "unknown", n, time.Now().Add(-time.Minute)) |
| 364 | for _, cookies := range [][]*http.Cookie{{pending, inbox, local, local}, {pending, inbox, {Name: f.b.sessionCookie, Value: strings.Repeat("A", 43)}}} { |
| 365 | w := httptest.NewRecorder() |
| 366 | f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", cookies)) |
| 367 | if w.Code != 403 { |
| 368 | t.Fatal("unresolved/ambiguous current account accepted", w.Code) |
| 369 | } |
| 370 | bridgeNoSession(t, f.b, w) |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | func TestHomeBridgeCallbackHasNoSessionDependencyAndExactOrigin(t *testing.T) { |
| 375 | f := newBridgeFixture(t) |
| 376 | _, n := f.start(t, nil, "/") |
| 377 | id, _ := assertion.RandomID() |
| 378 | now := time.Now().Unix() |
| 379 | 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}) |
| 380 | if err != nil { |
| 381 | t.Fatal(err) |
| 382 | } |
| 383 | for _, origin := range []string{"", "https://evil.example", bridgeIssuer} { |
| 384 | r := httptest.NewRequest(http.MethodPost, bridgeOrigin+"/auth/assertion", strings.NewReader(url.Values{"assertion": {token}}.Encode())) |
| 385 | r.Header.Set("Origin", origin) |
| 386 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 387 | // Even malformed duplicate app cookies cannot affect the issuer callback. |
| 388 | r.AddCookie(&http.Cookie{Name: f.b.sessionCookie, Value: "bad"}) |
| 389 | r.AddCookie(&http.Cookie{Name: f.b.sessionCookie, Value: "bad"}) |
| 390 | w := httptest.NewRecorder() |
| 391 | f.b.Callback(w, r) |
| 392 | bridgeNoSession(t, f.b, w) |
| 393 | if origin == bridgeIssuer && w.Code != 303 { |
| 394 | t.Fatal("callback resolved local session", w.Code) |
| 395 | } |
| 396 | if origin != bridgeIssuer && w.Code == 303 { |
| 397 | t.Fatal("callback accepted different origin") |
| 398 | } |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | func TestHomeBridgeOrdinaryProofCannotBecomeLinkProof(t *testing.T) { |
| 403 | f := newBridgeFixture(t) |
| 404 | local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 405 | w := httptest.NewRecorder() |
| 406 | f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})) |
| 407 | init := bridgeCookie(t, w, homeInitCookie) |
| 408 | // Bind an explicit link to its own generated nonce. |
| 409 | f.start(t, []*http.Cookie{local, init}, "/account/home/confirm") |
| 410 | // A separate ordinary sign-in produces a valid assertion with another nonce. |
| 411 | pending, n := f.start(t, []*http.Cookie{local}, "/") |
| 412 | inbox := f.callback(t, "opaque", n, time.Now().Add(-time.Minute)) |
| 413 | finish := httptest.NewRecorder() |
| 414 | f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{local, init, pending, inbox})) |
| 415 | if finish.Code != 403 { |
| 416 | t.Fatal("ordinary exchange reused for linking", finish.Code) |
| 417 | } |
| 418 | bridgeNoSession(t, f.b, finish) |
| 419 | for _, cookie := range finish.Result().Cookies() { |
| 420 | if cookie.Name == homeProofCookie && cookie.MaxAge > 0 { |
| 421 | t.Fatal("wrong nonce created proof") |
| 422 | } |
| 423 | } |
| 424 | var rows int |
| 425 | if err := f.db.QueryRow("SELECT COUNT(*) FROM home_link_flows").Scan(&rows); err != nil || rows != 0 { |
| 426 | t.Fatal("failed link initiation not burned", rows, err) |
| 427 | } |
| 428 | } |
| 429 | |
| 430 | func TestHomeBridgeStaleFreshExchangeCannotMintOrCreateProof(t *testing.T) { |
| 431 | f := newBridgeFixture(t) |
| 432 | local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 433 | w := httptest.NewRecorder() |
| 434 | f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})) |
| 435 | init := bridgeCookie(t, w, homeInitCookie) |
| 436 | pending, n := f.start(t, []*http.Cookie{local, init}, "/account/home/confirm") |
| 437 | inbox := f.callback(t, "opaque", n, time.Now().Add(-6*time.Minute)) |
| 438 | for i := 0; i < 2; i++ { |
| 439 | finish := httptest.NewRecorder() |
| 440 | f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{local, init, pending, inbox})) |
| 441 | if finish.Code == 303 { |
| 442 | t.Fatal("stale Home proof accepted or revived") |
| 443 | } |
| 444 | bridgeNoSession(t, f.b, finish) |
| 445 | } |
| 446 | var rows int |
| 447 | if err := f.db.QueryRow("SELECT COUNT(*) FROM home_link_flows WHERE stage = 'pending'").Scan(&rows); err != nil || rows != 0 { |
| 448 | t.Fatal("stale Home gesture created a pending link", rows, err) |
| 449 | } |
| 450 | } |
| 451 | |
| 452 | func TestHomeBridgeCurrentHomeSubjectIsCheckedByConsumer(t *testing.T) { |
| 453 | f := newBridgeFixture(t) |
| 454 | local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 455 | f.link(t, local, "subject-one") |
| 456 | other := f.local(t, "two@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 457 | f.link(t, other, "subject-two") |
| 458 | homeToken, err := f.sessions.Mint(Session{Subject: "subject-one", Method: HomeMethod, AuthTime: time.Now().Add(-time.Minute)}) |
| 459 | if err != nil { |
| 460 | t.Fatal(err) |
| 461 | } |
| 462 | current := &http.Cookie{Name: f.b.sessionCookie, Value: homeToken} |
| 463 | pending, n := f.start(t, []*http.Cookie{current}, "/") |
| 464 | inbox := f.callback(t, "subject-two", n, time.Now().Add(-time.Minute)) |
| 465 | w := httptest.NewRecorder() |
| 466 | f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{current, pending, inbox})) |
| 467 | if w.Code == 303 { |
| 468 | t.Fatal("signed-in Home identity silently replaced") |
| 469 | } |
| 470 | bridgeNoSession(t, f.b, w) |
| 471 | } |
| 472 | |
| 473 | func TestHomeBridgeReturnPathCallbackRemainsSafe(t *testing.T) { |
| 474 | 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", "/"}, {"", "/"}} { |
| 475 | t.Run(tc.input, func(t *testing.T) { |
| 476 | f := newBridgeFixture(t) |
| 477 | f.b.returnPath = func(*http.Request) string { return tc.input } |
| 478 | local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| 479 | proof, cookies := f.linkProof(t, local, "opaque") |
| 480 | w := httptest.NewRecorder() |
| 481 | f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", append(cookies, proof))) |
| 482 | if w.Code != 303 || w.Header().Get("Location") != tc.want { |
| 483 | t.Fatalf("return location=%q status=%d", w.Header().Get("Location"), w.Code) |
| 484 | } |
| 485 | }) |
| 486 | } |
| 487 | } |
| 488 | |