oficina / docs Public

Bridge Home assertions to explicit existing Docs account links

Co-Authored-By: Codex <codex@openai.com>
Paul Campbell pushed by paul@keymail.dev e8f7bbc3bc8bf0097d986412e7572928860e133a parent 3598314
3 files changed, +853 −0
  • internal/docs/home_exchange.go +302 −0
  • internal/docs/home_exchange_test.go +437 −0
  • internal/docs/home_link_handlers.go +114 −0
diff --git a/internal/docs/home_exchange.go b/internal/docs/home_exchange.go
new file mode 100644
index 0000000..19a2f8f
--- /dev/null
+++ b/internal/docs/home_exchange.go
@@ -0,0 +1,302 @@
+package docs
+
+import (
+ "context"
+ "database/sql"
+ "encoding/base64"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/oficina/home/handoff/seal"
+ "amadan.net/rastrillo/rastrillo/assertion"
+ "github.com/carlosframework/docs/internal/docs/linkstore"
+ rauth "github.com/carlosframework/rastrillo/auth"
+ "github.com/carlosframework/rastrillo/sessions"
+ "gorm.io/gorm"
+)
+
+const homeInitCookie = "__Host-docs-home-init"
+const homeProofCookie = "__Host-docs-home-proof"
+
+var errHomeBridge = errors.New("docs: Home exchange unavailable")
+
+type homeBridge struct {
+ origin string
+ sessionCookie string
+ sessions *sessions.Sessions
+ links *linkstore.Store
+ resolver identityResolver
+ ordinary, fresh *handoff.Consumer
+ render *renderer
+ store *Store
+ now func() time.Time
+ // returnPath reads separate sealed navigation state; it carries no identity.
+ returnPath func(*http.Request) string
+}
+type homeFinishKey struct{}
+type homeFinishResult struct {
+ session *sessions.Session
+ init string
+ state string
+ status int
+}
+
+// Both subsystem schemas must be migrated before construction. The consumers
+// share one replay store; choosing a different Finish cannot weaken freshness.
+func newHomeBridge(settings *homeSettings, origin string, db *sql.DB, auth *rauth.Auth, render *renderer, store *Store) (*homeBridge, error) {
+ if settings == nil || db == nil || auth == nil || render == nil || store == nil {
+ return nil, errHomeBridge
+ }
+ sealer, err := seal.New(settings.dek, "oficina/docs")
+ if err != nil {
+ return nil, errHomeBridge
+ }
+ exchange, err := handoff.NewStore(db, sealer, settings.issuer, origin, settings.keys)
+ if err != nil {
+ return nil, errHomeBridge
+ }
+ linker, err := linkstore.New(db, settings.dek, origin, settings.issuer)
+ if err != nil {
+ return nil, errHomeBridge
+ }
+ sessionStore, err := sessions.New(sessions.Config{DB: db, Origin: origin})
+ if err != nil {
+ return nil, errHomeBridge
+ }
+ if auth.SessionCookie() != sessionStore.CookieName() {
+ return nil, errHomeBridge
+ }
+ b := &homeBridge{origin: origin, sessionCookie: auth.SessionCookie(), sessions: sessionStore, links: linker, render: render, store: store, now: time.Now}
+ b.resolver = identityResolver{store: store, issuer: settings.issuer, linkedPerson: func(ctx context.Context, issuer, subject string) (int64, error) {
+ id, _, err := linker.Resolve(ctx, issuer, subject)
+ if errors.Is(err, linkstore.ErrRefused) {
+ return 0, nil
+ }
+ if err != nil {
+ return 0, errIdentityStorage
+ }
+ return id, nil
+ }}
+ cfg := handoff.ConsumerConfig{Store: exchange, ConsumerID: settings.consumerID, Complete: b.complete, Fail: b.fail, Now: func() time.Time { return b.now() }, CurrentSubject: func(r *http.Request) string {
+ result, _ := r.Context().Value(homeFinishKey{}).(*homeFinishResult)
+ if result != nil && result.session != nil && result.session.Method == homeAssertionMethod {
+ return result.session.Subject
+ }
+ return ""
+ }}
+ b.ordinary, err = handoff.NewConsumer(cfg)
+ if err != nil {
+ return nil, errHomeBridge
+ }
+ cfg.RequireFreshAuth = true
+ cfg.BindStart = func(r *http.Request, nonce string) error {
+ secret, present, ok := homeCookie(r, homeInitCookie)
+ if !present || !ok {
+ return errIdentityRefused
+ }
+ local, _, err := b.localBinding(r)
+ if err != nil {
+ return err
+ }
+ return b.links.BindNonce(r.Context(), secret, local, nonce, b.now())
+ }
+ b.fresh, err = handoff.NewConsumer(cfg)
+ if err != nil {
+ return nil, errHomeBridge
+ }
+ if exchange.Sweep(context.Background(), b.now()) != nil || linker.Sweep(context.Background(), b.now()) != nil {
+ return nil, errHomeBridge
+ }
+ return b, nil
+}
+
+// homeCookie distinguishes absent cookies from malformed or duplicate values.
+// Neither a duplicate nor an invalid link cookie may select ordinary sign-in.
+func homeCookie(r *http.Request, name string) (value string, present, valid bool) {
+ count := 0
+ for _, c := range r.Cookies() {
+ if c.Name == name {
+ count++
+ value = c.Value
+ }
+ }
+ if count == 0 {
+ return "", false, true
+ }
+ if count != 1 || len(value) != 43 {
+ return "", true, false
+ }
+ raw, err := base64.RawURLEncoding.Strict().DecodeString(value)
+ return value, true, err == nil && len(raw) == 32 && base64.RawURLEncoding.EncodeToString(raw) == value
+}
+func setHomeCookie(w http.ResponseWriter, name, value string, seconds int) {
+ http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", MaxAge: seconds, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode})
+}
+func (b *homeBridge) actualSession(r *http.Request) (*sessions.Session, error) {
+ _, present, ok := homeCookie(r, b.sessionCookie)
+ if !ok {
+ return nil, errIdentityRefused
+ }
+ if !present {
+ return nil, nil
+ }
+ session, ok := b.sessions.From(r)
+ if !ok {
+ return nil, errIdentityRefused
+ }
+ return &session, nil
+}
+func (b *homeBridge) Start(w http.ResponseWriter, r *http.Request) {
+ _, present, ok := homeCookie(r, homeInitCookie)
+ if !ok {
+ b.fail(w, r, http.StatusBadRequest)
+ return
+ }
+ // Duplicate auth cookies must fail before a fresh hook or future mint.
+ if _, err := b.actualSession(r); err != nil {
+ b.fail(w, r, http.StatusForbidden)
+ return
+ }
+ if present {
+ b.fresh.Start(w, r)
+ } else {
+ b.ordinary.Start(w, r)
+ }
+}
+func (b *homeBridge) Callback(w http.ResponseWriter, r *http.Request) { b.ordinary.Callback(w, r) }
+func (b *homeBridge) Finish(w http.ResponseWriter, r *http.Request) {
+ init, _, ok := homeCookie(r, homeInitCookie)
+ if !ok {
+ b.fail(w, r, http.StatusBadRequest)
+ return
+ }
+ _, _, ok = homeCookie(r, homeProofCookie)
+ if !ok {
+ b.fail(w, r, http.StatusBadRequest)
+ return
+ }
+ session, err := b.actualSession(r)
+ if err != nil {
+ b.fail(w, r, http.StatusForbidden)
+ return
+ }
+ result := &homeFinishResult{session: session, init: init}
+ b.ordinary.Finish(w, r.WithContext(context.WithValue(r.Context(), homeFinishKey{}, result)))
+}
+func (b *homeBridge) fail(w http.ResponseWriter, r *http.Request, status int) {
+ state := "failed"
+ if result, ok := r.Context().Value(homeFinishKey{}).(*homeFinishResult); ok && result.state != "" {
+ state, status = result.state, result.status
+ }
+ b.render.homeLink(w, r, state, "", status)
+}
+func rejectHome(r *http.Request, state string, status int) error {
+ if result, ok := r.Context().Value(homeFinishKey{}).(*homeFinishResult); ok {
+ result.state, result.status = state, status
+ }
+ return errIdentityRefused
+}
+func (b *homeBridge) complete(w http.ResponseWriter, r *http.Request, claims assertion.Claims) error {
+ result, ok := r.Context().Value(homeFinishKey{}).(*homeFinishResult)
+ if !ok {
+ return errHomeBridge
+ }
+ if result.init != "" {
+ setHomeCookie(w, homeInitCookie, "", -1)
+ local, _, err := b.localBinding(r)
+ if err != nil {
+ return rejectHome(r, "failed", http.StatusForbidden)
+ }
+ secret, err := b.links.Complete(r.Context(), result.init, local, linkstore.Home{Issuer: claims.Issuer, Subject: claims.Subject, Nonce: claims.Nonce, AuthTime: time.Unix(claims.AuthTime, 0)}, b.now())
+ if errors.Is(err, linkstore.ErrRefused) {
+ return rejectHome(r, "failed", http.StatusForbidden)
+ }
+ if err != nil {
+ return errHomeBridge
+ }
+ setHomeCookie(w, homeProofCookie, secret, 300)
+ return nil
+ }
+ id, _, err := b.links.Resolve(r.Context(), claims.Issuer, claims.Subject)
+ if errors.Is(err, linkstore.ErrRefused) {
+ return rejectHome(r, "unknown", http.StatusForbidden)
+ }
+ if err != nil {
+ return errHomeBridge
+ }
+ if result.session != nil {
+ person, err := b.existingPerson(r, *result.session)
+ if err != nil || person.ID != id {
+ return rejectHome(r, "failed", http.StatusForbidden)
+ }
+ }
+ if err := b.sessions.SignIn(w, r, sessions.Session{Subject: claims.Subject, Method: homeAssertionMethod, AuthTime: time.Unix(claims.AuthTime, 0)}); err != nil {
+ return errHomeBridge
+ }
+ return nil
+}
+
+// existingPerson never creates a person or samples, even during ordinary SSO.
+func (b *homeBridge) existingPerson(r *http.Request, session sessions.Session) (Person, error) {
+ var person Person
+ if session.Method == homeAssertionMethod {
+ id, _, err := b.links.Resolve(r.Context(), b.resolver.issuer, session.Subject)
+ if err != nil {
+ return person, errIdentityRefused
+ }
+ if err := b.store.db.WithContext(r.Context()).First(&person, id).Error; err != nil {
+ return Person{}, errIdentityRefused
+ }
+ } else {
+ if session.Method != "magiclink" && session.Method != "keymail" {
+ return person, errIdentityRefused
+ }
+ err := b.store.db.WithContext(r.Context()).Where("email = ?", subjectOf(session.Subject)).First(&person).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return Person{}, errIdentityRefused
+ }
+ if err != nil {
+ return Person{}, errIdentityStorage
+ }
+ }
+ if person.ID <= 0 || subjectOf(person.Email) == "" {
+ return Person{}, errIdentityRefused
+ }
+ return person, nil
+}
+func (b *homeBridge) localBinding(r *http.Request) (linkstore.Local, Person, error) {
+ session, err := b.actualSession(r)
+ if err != nil || session == nil {
+ return linkstore.Local{}, Person{}, errIdentityRefused
+ }
+ at, fresh := freshLocalAuthentication(*session, b.now())
+ if !fresh {
+ return linkstore.Local{}, Person{}, errIdentityRefused
+ }
+ person, err := b.existingPerson(r, *session)
+ if err != nil {
+ return linkstore.Local{}, Person{}, err
+ }
+ token, _, _ := homeCookie(r, b.sessionCookie)
+ return linkstore.Local{PersonID: person.ID, SessionToken: token, AuthTime: at}, person, nil
+}
+func (b *homeBridge) guard(w http.ResponseWriter, r *http.Request, method, path string) bool {
+ w.Header().Set("Cache-Control", "no-store")
+ if r.Host != strings.TrimPrefix(b.origin, "https://") || r.URL.Path != path {
+ b.fail(w, r, http.StatusNotFound)
+ return false
+ }
+ if r.Method != method {
+ w.Header().Set("Allow", method)
+ b.fail(w, r, http.StatusMethodNotAllowed)
+ return false
+ }
+ if r.URL.RawQuery != "" {
+ b.fail(w, r, http.StatusBadRequest)
+ return false
+ }
+ return true
+}
diff --git a/internal/docs/home_exchange_test.go b/internal/docs/home_exchange_test.go
new file mode 100644
index 0000000..8b491f4
--- /dev/null
+++ b/internal/docs/home_exchange_test.go
@@ -0,0 +1,437 @@
+package docs
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/rastrillo/rastrillo/assertion"
+ assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
+ "github.com/carlosframework/docs/internal/docs/linkstore"
+ rauth "github.com/carlosframework/rastrillo/auth"
+ "github.com/carlosframework/rastrillo/csrf"
+ "github.com/carlosframework/rastrillo/migrate"
+ "github.com/carlosframework/rastrillo/sessions"
+)
+
+const bridgeOrigin = "https://blue.docs.oficina.works"
+const bridgeIssuer = "https://id.oficina.works"
+
+type bridgeFixture struct {
+ b *homeBridge
+ app *App
+ key *assertioncrypto.Keypair
+}
+
+func newBridgeFixture(t *testing.T) *bridgeFixture {
+ t.Helper()
+ app, err := NewForTestWith(t, Config{Origin: bridgeOrigin, SampleDocuments: "false"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ schema := migrate.Merge(linkstore.Schema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff"))
+ if _, err := migrate.Apply(context.Background(), app.db, schema); err != nil {
+ t.Fatal(err)
+ }
+ key, err := assertioncrypto.Generate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ settings := &homeSettings{issuer: bridgeIssuer, consumerID: "blue-docs", keys: map[string][]byte{"key": key.SignPub()}, dek: bytes.Repeat([]byte{41}, 32)}
+ auth, err := rauth.New(rauth.Config{DB: app.db.Writer(), Origin: bridgeOrigin, InstanceKey: devInstanceKey, Logger: app.logger})
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := newHomeBridge(settings, bridgeOrigin, app.db.Writer(), auth, app.handlers.render, app.store)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &bridgeFixture{b: b, app: app, key: key}
+}
+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 *homeBridge, 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.app.store.EnsurePerson(context.Background(), address); err != nil {
+ t.Fatal(err)
+ }
+ token, err := f.b.sessions.Mint(sessions.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{})
+ person, err := f.app.store.EnsurePerson(context.Background(), "one@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ doc, err := f.app.store.Create(context.Background(), person, "Existing document")
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.link(t, local, "opaque-subject-one")
+ if _, ok := f.b.sessions.From(bridgeRequest(http.MethodGet, "/", []*http.Cookie{local})); !ok {
+ t.Fatal("linking replaced local session")
+ }
+ pending, nonce := f.start(t, nil, "/d/"+doc.Sqid)
+ 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/"+doc.Sqid {
+ 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.b.sessions.From(r)
+ if !ok || session.Method != homeAssertionMethod || session.Subject != "opaque-subject-one" || !session.AuthTime.Equal(original) {
+ t.Fatalf("wrong Home session: %+v", session)
+ }
+ identity, err := f.b.resolver.resolve(sessions.WithSession(r, session))
+ if err != nil || identity.Actor.PersonID != person || identity.Actor.Subject != "one@example.com" {
+ t.Fatalf("identity=%+v %v", identity, err)
+ }
+ if _, err := f.app.store.Get(context.Background(), person, doc.Sqid); err != nil {
+ t.Fatal("existing ownership changed", err)
+ }
+ var count int
+ if err := f.app.db.Writer().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" && !strings.Contains(w.Body.String(), "/signin/local") {
+ t.Fatal("unknown identity lacks local continuation")
+ }
+ 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", homeAssertionMethod} {
+ 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 TestHomeBridgeLinkMutationsRemainBehindCSRF(t *testing.T) {
+ f := newBridgeFixture(t)
+ local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute))
+ protected := csrf.Protect(bridgeOrigin)(http.HandlerFunc(f.b.LinkStart))
+ r := bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})
+ r.Header.Set("Origin", "https://evil.example")
+ r.Header.Set("Sec-Fetch-Site", "cross-site")
+ w := httptest.NewRecorder()
+ protected.ServeHTTP(w, r)
+ if w.Code != 403 {
+ t.Fatal("cross-site link initiation accepted", w.Code)
+ }
+ var count int
+ if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM docs_link_flows").Scan(&count); err != nil || count != 0 {
+ t.Fatal("cross-site initiation retained state", count, err)
+ }
+}
+
+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.app.db.Writer().QueryRow("SELECT COUNT(*) FROM docs_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.app.db.Writer().QueryRow("SELECT COUNT(*) FROM docs_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.b.sessions.Mint(sessions.Session{Subject: "subject-one", Method: homeAssertionMethod, 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)
+ }
+ })
+ }
+}
diff --git a/internal/docs/home_link_handlers.go b/internal/docs/home_link_handlers.go
new file mode 100644
index 0000000..1a80321
--- /dev/null
+++ b/internal/docs/home_link_handlers.go
@@ -0,0 +1,114 @@
+package docs
+
+import (
+ "errors"
+ "net/http"
+
+ "amadan.net/oficina/home/handoff"
+ "github.com/carlosframework/docs/internal/docs/linkstore"
+)
+
+// Link routes belong behind the app's CSRF, session, identity and admission
+// middleware. They recheck the actual cookie at each sensitive transition.
+func (b *homeBridge) LinkPage(w http.ResponseWriter, r *http.Request) {
+ if !b.guard(w, r, http.MethodGet, "/account/home") {
+ return
+ }
+ session, err := b.actualSession(r)
+ if err != nil || session == nil {
+ b.fail(w, r, http.StatusForbidden)
+ return
+ }
+ person, err := b.existingPerson(r, *session)
+ if err != nil {
+ b.fail(w, r, http.StatusForbidden)
+ return
+ }
+ b.render.homeLink(w, r, "start", person.Email, http.StatusOK)
+}
+func (b *homeBridge) LinkStart(w http.ResponseWriter, r *http.Request) {
+ if !b.guard(w, r, http.MethodPost, "/account/home") {
+ return
+ }
+ for _, name := range []string{homeInitCookie, homeProofCookie} {
+ if _, _, ok := homeCookie(r, name); !ok {
+ b.fail(w, r, http.StatusBadRequest)
+ return
+ }
+ }
+ local, _, err := b.localBinding(r)
+ // The reviewed local reauthentication continuation is wired separately.
+ // Stale local authentication cannot begin or reuse a linking initiation.
+ if err != nil {
+ b.fail(w, r, http.StatusForbidden)
+ return
+ }
+ path := "/"
+ if b.returnPath != nil {
+ if candidate := b.returnPath(r); handoff.SafePath(candidate) {
+ path = candidate
+ }
+ }
+ secret, err := b.links.Start(r.Context(), local, path, b.now())
+ if err != nil {
+ b.linkFailure(w, r, err)
+ return
+ }
+ setHomeCookie(w, homeInitCookie, secret, 300)
+ setHomeCookie(w, homeProofCookie, "", -1)
+ http.Redirect(w, r, "/auth/start?return_to=%2Faccount%2Fhome%2Fconfirm", http.StatusSeeOther)
+}
+func (b *homeBridge) ConfirmPage(w http.ResponseWriter, r *http.Request) {
+ if !b.guard(w, r, http.MethodGet, "/account/home/confirm") {
+ return
+ }
+ secret, present, ok := homeCookie(r, homeProofCookie)
+ if !ok || !present {
+ b.fail(w, r, http.StatusBadRequest)
+ return
+ }
+ local, person, err := b.localBinding(r)
+ if err != nil {
+ b.fail(w, r, http.StatusForbidden)
+ return
+ }
+ if _, err := b.links.Pending(r.Context(), secret, local, b.now()); err != nil {
+ if errors.Is(err, linkstore.ErrRefused) {
+ setHomeCookie(w, homeProofCookie, "", -1)
+ }
+ b.linkFailure(w, r, err)
+ return
+ }
+ b.render.homeLink(w, r, "confirm", person.Email, http.StatusOK)
+}
+func (b *homeBridge) ConfirmPost(w http.ResponseWriter, r *http.Request) {
+ if !b.guard(w, r, http.MethodPost, "/account/home/confirm") {
+ return
+ }
+ secret, present, ok := homeCookie(r, homeProofCookie)
+ if !ok || !present {
+ b.fail(w, r, http.StatusBadRequest)
+ return
+ }
+ local, _, err := b.localBinding(r)
+ if err != nil {
+ b.fail(w, r, http.StatusForbidden)
+ return
+ }
+ pending, err := b.links.Confirm(r.Context(), secret, local, b.now())
+ if err == nil || errors.Is(err, linkstore.ErrRefused) {
+ setHomeCookie(w, homeProofCookie, "", -1)
+ }
+ if err != nil {
+ b.linkFailure(w, r, err)
+ return
+ }
+ http.Redirect(w, r, pending.ReturnPath, http.StatusSeeOther)
+}
+func (b *homeBridge) linkFailure(w http.ResponseWriter, r *http.Request, err error) {
+ status := http.StatusServiceUnavailable
+ if errors.Is(err, linkstore.ErrRefused) {
+ status = http.StatusForbidden
+ }
+ b.fail(w, r, status)
+}