package connect import ( "amadan.net/oficina/home/connect/linkstore" "amadan.net/oficina/home/handoff" "amadan.net/rastrillo/rastrillo/assertion" assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" "bytes" "context" "crypto/rand" "database/sql" "encoding/base64" "io/fs" _ "modernc.org/sqlite" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time" ) const bridgeOrigin = "https://blue.docs.oficina.works" const bridgeIssuer = "https://id.oficina.works" type fakeSessions struct { cookie string values map[string]Session } func (f *fakeSessions) Mint(s Session) (string, error) { raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", err } token := base64.RawURLEncoding.EncodeToString(raw) s.At = time.Now() f.values[token] = s return token, nil } func (f *fakeSessions) From(r *http.Request) (Session, bool) { c, e := r.Cookie(f.cookie) if e != nil { return Session{}, false } s, ok := f.values[c.Value] return s, ok } type bridgeFixture struct { b *Bridge db *sql.DB key *assertioncrypto.Keypair sessions *fakeSessions } func newBridgeFixture(t *testing.T) *bridgeFixture { t.Helper() d, e := sql.Open("sqlite", filepath.Join(t.TempDir(), "connect.db")+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)") if e != nil { t.Fatal(e) } t.Cleanup(func() { d.Close() }) for _, schema := range []fs.FS{handoff.Migrations, linkstore.Migrations} { files, e := fs.Glob(schema, "migrations/*.sql") if e != nil { t.Fatal(e) } for _, name := range files { raw, e := fs.ReadFile(schema, name) if e != nil { t.Fatal(e) } if _, e = d.Exec(string(raw)); e != nil { t.Fatal(e) } } } if _, e = d.Exec("CREATE TABLE people(id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE)"); e != nil { t.Fatal(e) } key, e := assertioncrypto.Generate() if e != nil { t.Fatal(e) } sessions := &fakeSessions{cookie: "__Host-local-session", values: map[string]Session{}} f := &bridgeFixture{db: d, key: key, sessions: sessions} 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, ReadSession: func(r *http.Request) (*Session, error) { s, ok := sessions.From(r) if !ok { return nil, nil } return &s, nil }, MintHomeSession: func(w http.ResponseWriter, r *http.Request, s Session) error { token, e := sessions.Mint(s) if e != nil { return e } http.SetCookie(w, &http.Cookie{Name: sessions.cookie, Value: token, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) return nil }, LookupLocalSession: func(ctx context.Context, s Session) (Person, error) { var p Person e := d.QueryRowContext(ctx, "SELECT id,email FROM people WHERE email=?", s.Subject).Scan(&p.ID, &p.Label) return p, e }, LookupPerson: func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) { var label string e := q.QueryRowContext(ctx, "SELECT email FROM people WHERE id=? AND email<>''", id).Scan(&label) return label, e }, Render: func(w http.ResponseWriter, r *http.Request, state, label string, status int) { w.WriteHeader(status) w.Write([]byte(state)) }} f.b, e = New(cfg) if e != nil { t.Fatal(e) } return f } func bridgeRequest(method, path string, cookies []*http.Cookie) *http.Request { r := httptest.NewRequest(method, bridgeOrigin+path, nil) for _, cookie := range cookies { r.AddCookie(cookie) } return r } func bridgeCookie(t *testing.T, w *httptest.ResponseRecorder, name string) *http.Cookie { t.Helper() for _, cookie := range w.Result().Cookies() { if cookie.Name == name && cookie.MaxAge >= 0 { if !cookie.Secure || !cookie.HttpOnly || cookie.Domain != "" || cookie.Path != "/" || cookie.SameSite != http.SameSiteLaxMode { t.Fatal("unsafe cookie attributes") } return cookie } } t.Fatalf("missing cookie %s; status=%d", name, w.Code) return nil } func bridgeNoSession(t *testing.T, b *Bridge, w *httptest.ResponseRecorder) { t.Helper() for _, c := range w.Result().Cookies() { if c.Name == b.sessionCookie { t.Fatal("unexpected app session mutation") } } } func (f *bridgeFixture) local(t *testing.T, address, method string, authTime time.Time) *http.Cookie { t.Helper() if _, err := f.db.Exec("INSERT INTO people(email) VALUES(?) ON CONFLICT DO NOTHING", address); err != nil { t.Fatal(err) } token, err := f.sessions.Mint(Session{Subject: address, Method: method, AuthTime: authTime}) if err != nil { t.Fatal(err) } return &http.Cookie{Name: f.b.sessionCookie, Value: token} } func (f *bridgeFixture) start(t *testing.T, cookies []*http.Cookie, path string) (*http.Cookie, string) { t.Helper() w := httptest.NewRecorder() f.b.Start(w, bridgeRequest(http.MethodGet, "/auth/start?return_to="+url.QueryEscape(path), cookies)) if w.Code != 303 { t.Fatalf("start=%d body=%s", w.Code, w.Body.String()) } target, err := url.Parse(w.Header().Get("Location")) if err != nil { t.Fatal(err) } return bridgeCookie(t, w, "__Host-oficina-assertion-pending"), target.Query().Get("nonce") } func (f *bridgeFixture) callback(t *testing.T, subject, nonce string, authTime time.Time) *http.Cookie { t.Helper() now := time.Now().Unix() id, err := assertion.RandomID() if err != nil { t.Fatal(err) } 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()}) if err != nil { t.Fatal(err) } form := url.Values{"assertion": {token}} r := httptest.NewRequest(http.MethodPost, bridgeOrigin+"/auth/assertion", strings.NewReader(form.Encode())) r.Header.Set("Origin", bridgeIssuer) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() f.b.Callback(w, r) if w.Code != 303 { t.Fatalf("callback=%d", w.Code) } bridgeNoSession(t, f.b, w) return bridgeCookie(t, w, "__Host-oficina-assertion-inbox") } func (f *bridgeFixture) linkProof(t *testing.T, local *http.Cookie, subject string) (*http.Cookie, []*http.Cookie) { t.Helper() w := httptest.NewRecorder() f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})) if w.Code != 303 { t.Fatalf("link start=%d", w.Code) } init := bridgeCookie(t, w, homeInitCookie) cookies := []*http.Cookie{local, init} start := httptest.NewRecorder() f.b.Start(start, bridgeRequest(http.MethodGet, w.Header().Get("Location"), cookies)) if start.Code != 303 { t.Fatalf("fresh start=%d", start.Code) } target, _ := url.Parse(start.Header().Get("Location")) if target.Query().Get("max_age") != "300" { t.Fatal("link exchange did not request fresh authentication") } pending := bridgeCookie(t, start, "__Host-oficina-assertion-pending") inbox := f.callback(t, subject, target.Query().Get("nonce"), time.Now().Add(-30*time.Second)) finish := httptest.NewRecorder() f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", append(cookies, pending, inbox))) if finish.Code != 303 || finish.Header().Get("Location") != "/account/home/confirm" { t.Fatalf("link finish=%d location=%q", finish.Code, finish.Header().Get("Location")) } bridgeNoSession(t, f.b, finish) return bridgeCookie(t, finish, homeProofCookie), []*http.Cookie{local} } func (f *bridgeFixture) link(t *testing.T, local *http.Cookie, subject string) { t.Helper() proof, cookies := f.linkProof(t, local, subject) cookies = append(cookies, proof) page := httptest.NewRecorder() f.b.ConfirmPage(page, bridgeRequest(http.MethodGet, "/account/home/confirm", cookies)) if page.Code != 200 { t.Fatalf("confirm page=%d", page.Code) } w := httptest.NewRecorder() f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", cookies)) if w.Code != 303 || w.Header().Get("Location") != "/" { t.Fatalf("confirm=%d", w.Code) } bridgeNoSession(t, f.b, w) } func TestHomeBridgeLinkThenOrdinarySignInPreservesIdentity(t *testing.T) { f := newBridgeFixture(t) local := f.local(t, "one@example.com", "magiclink", time.Time{}) var person int64 if err := f.db.QueryRow("SELECT id FROM people WHERE email='one@example.com'").Scan(&person); err != nil { t.Fatal(err) } if _, err := f.db.Exec("CREATE TABLE content(owner INTEGER, bytes BLOB); INSERT INTO content VALUES(1,x'010203')"); err != nil { t.Fatal(err) } f.link(t, local, "opaque-subject-one") if _, ok := f.sessions.From(bridgeRequest(http.MethodGet, "/", []*http.Cookie{local})); !ok { t.Fatal("linking replaced local session") } pending, nonce := f.start(t, nil, "/d/existing") original := time.Now().Add(-time.Hour).Truncate(time.Second) inbox := f.callback(t, "opaque-subject-one", nonce, original) finish := httptest.NewRecorder() f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox})) if finish.Code != 303 || finish.Header().Get("Location") != "/d/existing" { t.Fatalf("ordinary finish=%d %q", finish.Code, finish.Header().Get("Location")) } cookie := bridgeCookie(t, finish, f.b.sessionCookie) r := bridgeRequest(http.MethodGet, "/", []*http.Cookie{cookie}) session, ok := f.sessions.From(r) if !ok || session.Method != HomeMethod || session.Subject != "opaque-subject-one" || !session.AuthTime.Equal(original) { t.Fatalf("wrong Home session: %+v", session) } identity, err := f.b.ResolveSession(r) if err != nil || identity.ID != person || identity.Label != "one@example.com" { t.Fatal("identity changed", err) } var content []byte if err = f.db.QueryRow("SELECT bytes FROM content WHERE owner=?", person).Scan(&content); err != nil || !bytes.Equal(content, []byte{1, 2, 3}) { t.Fatal("existing ownership changed", err) } var count int if err := f.db.QueryRow("SELECT COUNT(*) FROM people").Scan(&count); err != nil || count != 1 { t.Fatal("SSO created an extra person", count, err) } replay := httptest.NewRecorder() f.b.Finish(replay, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox})) if replay.Code == 303 { t.Fatal("assertion replay minted session") } bridgeNoSession(t, f.b, replay) } func TestHomeBridgeUnknownAndDifferentCurrentAccountNeverMint(t *testing.T) { f := newBridgeFixture(t) local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) other := f.local(t, "two@example.com", "magiclink", time.Now().Add(-time.Minute)) f.link(t, local, "linked-subject") for _, tc := range []struct { name, subject string cookies []*http.Cookie }{{"unknown", "unknown-private-subject", nil}, {"different-local", "linked-subject", []*http.Cookie{other}}} { t.Run(tc.name, func(t *testing.T) { pending, n := f.start(t, tc.cookies, "/") inbox := f.callback(t, tc.subject, n, time.Now().Add(-time.Minute)) w := httptest.NewRecorder() f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", append(tc.cookies, pending, inbox))) if w.Code != 403 { t.Fatalf("rejected exchange=%d", w.Code) } bridgeNoSession(t, f.b, w) if tc.name == "unknown" && w.Body.String() != "unknown" { t.Fatal("unknown identity did not select local continuation state") } if strings.Contains(w.Body.String(), tc.subject) { t.Fatal("opaque identity leaked into response") } retry := httptest.NewRecorder() f.b.Finish(retry, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox})) if retry.Code == 303 { t.Fatal("rejected completion remained reusable") } }) } } func TestHomeBridgeChangedLocalSessionAndProofReplayRefused(t *testing.T) { f := newBridgeFixture(t) authTime := time.Now().Add(-time.Minute) original := f.local(t, "one@example.com", "magiclink", authTime) proof, cookies := f.linkProof(t, original, "opaque") rotated := f.local(t, "one@example.com", "magiclink", authTime) w := httptest.NewRecorder() f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", []*http.Cookie{rotated, proof})) if w.Code != 403 { t.Fatal("different session linked proof", w.Code) } retry := httptest.NewRecorder() f.b.ConfirmPost(retry, bridgeRequest(http.MethodPost, "/account/home/confirm", append(cookies, proof))) if retry.Code != 403 { t.Fatal("rejected proof reusable", retry.Code) } } func TestHomeBridgeStrictCookiesAndStaleLocalRefusal(t *testing.T) { f := newBridgeFixture(t) local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) 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)}}} { w := httptest.NewRecorder() f.b.Start(w, bridgeRequest(http.MethodGet, "/auth/start", cookies)) if w.Code == 303 { t.Fatal("malformed/duplicate/unresolved cookie downgraded into ordinary sign-in") } } for _, method := range []string{"magiclink", "keymail", HomeMethod} { cookie := f.local(t, "one@example.com", method, time.Now().Add(-6*time.Minute)) w := httptest.NewRecorder() f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{cookie})) if w.Code != 403 { t.Fatal("stale or Home method began linking", method, w.Code) } } pending, n := f.start(t, nil, "/") inbox := f.callback(t, "unknown", n, time.Now().Add(-time.Minute)) for _, cookies := range [][]*http.Cookie{{pending, inbox, local, local}, {pending, inbox, {Name: f.b.sessionCookie, Value: strings.Repeat("A", 43)}}} { w := httptest.NewRecorder() f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", cookies)) if w.Code != 403 { t.Fatal("unresolved/ambiguous current account accepted", w.Code) } bridgeNoSession(t, f.b, w) } } func TestHomeBridgeCallbackHasNoSessionDependencyAndExactOrigin(t *testing.T) { f := newBridgeFixture(t) _, n := f.start(t, nil, "/") id, _ := assertion.RandomID() now := time.Now().Unix() 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}) if err != nil { t.Fatal(err) } for _, origin := range []string{"", "https://evil.example", bridgeIssuer} { r := httptest.NewRequest(http.MethodPost, bridgeOrigin+"/auth/assertion", strings.NewReader(url.Values{"assertion": {token}}.Encode())) r.Header.Set("Origin", origin) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") // Even malformed duplicate app cookies cannot affect the issuer callback. r.AddCookie(&http.Cookie{Name: f.b.sessionCookie, Value: "bad"}) r.AddCookie(&http.Cookie{Name: f.b.sessionCookie, Value: "bad"}) w := httptest.NewRecorder() f.b.Callback(w, r) bridgeNoSession(t, f.b, w) if origin == bridgeIssuer && w.Code != 303 { t.Fatal("callback resolved local session", w.Code) } if origin != bridgeIssuer && w.Code == 303 { t.Fatal("callback accepted different origin") } } } func TestHomeBridgeOrdinaryProofCannotBecomeLinkProof(t *testing.T) { f := newBridgeFixture(t) local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) w := httptest.NewRecorder() f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})) init := bridgeCookie(t, w, homeInitCookie) // Bind an explicit link to its own generated nonce. f.start(t, []*http.Cookie{local, init}, "/account/home/confirm") // A separate ordinary sign-in produces a valid assertion with another nonce. pending, n := f.start(t, []*http.Cookie{local}, "/") inbox := f.callback(t, "opaque", n, time.Now().Add(-time.Minute)) finish := httptest.NewRecorder() f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{local, init, pending, inbox})) if finish.Code != 403 { t.Fatal("ordinary exchange reused for linking", finish.Code) } bridgeNoSession(t, f.b, finish) for _, cookie := range finish.Result().Cookies() { if cookie.Name == homeProofCookie && cookie.MaxAge > 0 { t.Fatal("wrong nonce created proof") } } var rows int if err := f.db.QueryRow("SELECT COUNT(*) FROM home_link_flows").Scan(&rows); err != nil || rows != 0 { t.Fatal("failed link initiation not burned", rows, err) } } func TestHomeBridgeStaleFreshExchangeCannotMintOrCreateProof(t *testing.T) { f := newBridgeFixture(t) local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) w := httptest.NewRecorder() f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})) init := bridgeCookie(t, w, homeInitCookie) pending, n := f.start(t, []*http.Cookie{local, init}, "/account/home/confirm") inbox := f.callback(t, "opaque", n, time.Now().Add(-6*time.Minute)) for i := 0; i < 2; i++ { finish := httptest.NewRecorder() f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{local, init, pending, inbox})) if finish.Code == 303 { t.Fatal("stale Home proof accepted or revived") } bridgeNoSession(t, f.b, finish) } var rows int if err := f.db.QueryRow("SELECT COUNT(*) FROM home_link_flows WHERE stage = 'pending'").Scan(&rows); err != nil || rows != 0 { t.Fatal("stale Home gesture created a pending link", rows, err) } } func TestHomeBridgeCurrentHomeSubjectIsCheckedByConsumer(t *testing.T) { f := newBridgeFixture(t) local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) f.link(t, local, "subject-one") other := f.local(t, "two@example.com", "magiclink", time.Now().Add(-time.Minute)) f.link(t, other, "subject-two") homeToken, err := f.sessions.Mint(Session{Subject: "subject-one", Method: HomeMethod, AuthTime: time.Now().Add(-time.Minute)}) if err != nil { t.Fatal(err) } current := &http.Cookie{Name: f.b.sessionCookie, Value: homeToken} pending, n := f.start(t, []*http.Cookie{current}, "/") inbox := f.callback(t, "subject-two", n, time.Now().Add(-time.Minute)) w := httptest.NewRecorder() f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{current, pending, inbox})) if w.Code == 303 { t.Fatal("signed-in Home identity silently replaced") } bridgeNoSession(t, f.b, w) } func TestHomeBridgeReturnPathCallbackRemainsSafe(t *testing.T) { 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", "/"}, {"", "/"}} { t.Run(tc.input, func(t *testing.T) { f := newBridgeFixture(t) f.b.returnPath = func(*http.Request) string { return tc.input } local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) proof, cookies := f.linkProof(t, local, "opaque") w := httptest.NewRecorder() f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", append(cookies, proof))) if w.Code != 303 || w.Header().Get("Location") != tc.want { t.Fatalf("return location=%q status=%d", w.Header().Get("Location"), w.Code) } }) } }