oficina / docs Public

Bind existing Docs accounts through sealed single-use link proofs

Co-Authored-By: Codex <codex@openai.com>
Paul Campbell pushed by paul@keymail.dev 7f66b43ed0eae1c3f30a914c86e7c25e5691d1e8 parent 8a12672
7 files changed, +1098 −4
  • go.mod +3 −0
  • go.sum +4 −4
  • implementation/home-link-store.md +26 −0
  • internal/docs/linkstore/flows.go +314 −0
  • internal/docs/linkstore/migrations/0001_links.sql +19 −0
  • internal/docs/linkstore/store.go +198 −0
  • internal/docs/linkstore/store_test.go +534 −0

CI passed — run details

diff --git a/go.mod b/go.mod
index 4d87a39..af27f6f 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.26.5
require (
amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335
+ amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288
github.com/carlosframework/rastrillo v0.23.0
github.com/go-chi/chi/v5 v5.3.2
github.com/google/uuid v1.6.0
@@ -13,7 +14,9 @@ require (
)
require (
+ amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/keymaildev/signin v0.1.1 // indirect
diff --git a/go.sum b/go.sum
index c4c7b70..657556f 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,9 @@
-amadan.net/oficina/estilo.git v0.0.0-20260907201639-532746acdbc1 h1:9Qrzr6xT0QuaTAK4Bq3M8nWDCgkBmXy7H9BR3C+DZOo=
-amadan.net/oficina/estilo.git v0.0.0-20260907201639-532746acdbc1/go.mod h1:AZvUebwQkfBh2d0TIOAO/AViIIsRx774jL7ic8XOckg=
amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 h1:YogZKYINrPNXRfPedXxOIDbGCMY4ff7yAdohYphEWMg=
amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY=
-github.com/carlosframework/rastrillo v0.22.0 h1:tG02WAiy2VSYqmLT6kRfYbUaiJd/pBhTIWxxjjsEoI8=
-github.com/carlosframework/rastrillo v0.22.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
+amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288 h1:T1GQM8pChN+4+cpx7TUF7ogCczUaCw/D98YR8nH35NA=
+amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
+amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc=
+amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
github.com/carlosframework/rastrillo v0.23.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
diff --git a/implementation/home-link-store.md b/implementation/home-link-store.md
new file mode 100644
index 0000000..e12f971
--- /dev/null
+++ b/implementation/home-link-store.md
@@ -0,0 +1,26 @@
+# Existing-account link storage integration
+
+Implementation subtask of approved Docs46 design2e69825, on reviewed preview8a12672. No HTTP routes, UI, configuration, authentication adapter, new principals or deployment. The integrated branch still needs independent Fable review and the full Docs gate.
+
+`internal/docs/linkstore` exports:
+
+- `Schema`: additive subsystem migrations; merge after the existing Docs Schema in BootSchema. It references people but does not change people, documents, grants or content encodings.
+- `New(db *sql.DB, dek []byte, origin, issuer string) (*Store, error)`: exact configured HTTPS origins and a dedicated 32-byte storage/handoff DEK. Public shared seal package, namespace `oficina/docs`; never the content key. After migration/construction, call `Sweep(ctx, now)` at boot.
+- `Start(ctx, Local, returnPath, now) (initSecret, error)`: only after explicit same-origin POST and caller's method-aware local-auth predicate. Invalid navigation defaults to `/` through shared handoff.SafePath. Shared 10,000-row initiation/pending cap, swept on new starts.
+- `BindNonce(ctx, initSecret, Local, nonce, now) error`: shared consumer start hook, once, before redirect/cookie. Uses the shared ValidNonce format.
+- `Complete(ctx, initSecret, Local, Home, now) (pendingSecret, error)`: only after shared consumer has verified and consumed the assertion. Compares exact issuer, subject validity, nonce, current local person/session/auth time, and actual Home freshness. Never replaces local session.
+- `Pending(ctx, pendingSecret, Local, now) (Pending, error)`: confirmation continuation read, without installing a mapping.
+- `Confirm(ctx, pendingSecret, Local, now) (Pending, error)`: explicit same-origin confirmation POST; pending deletion and mapping write commit together.
+- `Resolve(ctx, issuer, subject) (personID, currentAddress, error)`: joins the exact configured issuer/subject mapping to the current existing person. No EnsurePerson or sample seeding.
+
+`Local` has PersonID, SessionToken and AuthTime. The caller derives these from the current independently verified local session, with the approved method-aware predicate: magiclink/keymail only, zero-time fallback only for actual local magiclink's stored At. SessionToken is the actual resolved session cookie, not Subject; this package hashes it immediately for storage. Repeating the same person under a different session or different auth time fails. Never construct Local from Home claims.
+
+`Home` contains already verified Issuer, Subject, Nonce and original AuthTime; never raw assertions or request parameters. `Pending` returns PersonID, ReturnPath and HomeAuthTime to server code. Both returned secrets need distinct host-only Secure/HttpOnly/Lax cookies and strict duplicate/missing-cookie handling in the caller. No proof, assertion or return path belongs in an unsealed cookie/query string.
+
+Initiation and pending JSON payloads are sealed to the stage, random-secret hash and exact local origin. The encrypted payload repeats its expiry and configured issuer. Raw session/cookie/nonce values, pending Home identity and resource paths do not appear in DB/WAL; permanent confirmed identity mappings intentionally contain issuer/subject/person as required by the design. Proof lifetimes end at the earliest initiation, local-auth or Home-auth five-minute deadline; the exact expiry instant is refused.
+
+The transaction takes a write lock before any reads or capacity checks. A persisted clock high-water prevents a restart or clock adjustment from reviving stale authentication. Within the shared consumer's 30-second backward tolerance, nonfuture checks use wall time while age and expiry use persisted high-water. Larger backward changes refuse before state access. This is a fail-closed implementation of dual freshness, not authority to refresh either authentication time. Clock/environment refusal retains state until recovery/expiry; it does not grant a link.
+
+A validly addressed row rejected for binding, freshness, tampering or mapping conflict is deleted and committed; it cannot become reusable if the conflicting mapping later disappears. Separate valid proofs for an already identical mapping are idempotent. Replaying the same cookie fails. Storage failures roll back both deletion and mapping, returning only a fixed storage error, so recovery may retry without a half-written mapping. No unlink/reassignment API exists.
+
+Validation: package race tests and vet pass. Tests cover two identities/persons, unchanged legacy rows, current address lookup, nonce/local session binding, freshness, invalid paths/cookies, sealed DB/WAL state, wrong origin/key/row/ciphertext/expiry, proof replay, concurrent unique links and 10,000-row admission, clock rollback, and injected storage failure. Meaningful Go overlay mutations in `/tmp/oficina-docs-link-store-mutations` fail for accepted cross-session proofs, accepted unbound/wrong nonce proofs, and reusable rejected conflict proofs. The root integrator owns whole-branch Fable review, full gate and real browser acceptance.
diff --git a/internal/docs/linkstore/flows.go b/internal/docs/linkstore/flows.go
new file mode 100644
index 0000000..2fbee38
--- /dev/null
+++ b/internal/docs/linkstore/flows.go
@@ -0,0 +1,314 @@
+package linkstore
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "io"
+ "time"
+
+ "amadan.net/oficina/home/handoff"
+)
+
+type flow struct {
+ Version int `json:"v"`
+ PersonID int64 `json:"person"`
+ SessionHash string `json:"session"`
+ LocalAuth int64 `json:"local_auth"`
+ Issuer string `json:"issuer"`
+ NonceHash string `json:"nonce,omitempty"`
+ Subject string `json:"subject,omitempty"`
+ HomeAuth int64 `json:"home_auth,omitempty"`
+ ReturnPath string `json:"return"`
+ Expires int64 `json:"expires"`
+}
+
+func (s *Store) purpose(stage, id string) string {
+ return "identity-link/v1/" + stage + "/" + id + "/" + s.origin
+}
+func (s *Store) put(ctx context.Context, tx *sql.Tx, stage, id string, f flow) error {
+ raw, err := json.Marshal(f)
+ if err != nil {
+ return ErrStorage
+ }
+ sealed, err := s.seal.Seal(s.purpose(stage, id), raw)
+ if err != nil {
+ return ErrStorage
+ }
+ _, err = tx.ExecContext(ctx, "INSERT INTO docs_link_flows (id_hash, stage, expires_at, payload) VALUES (?, ?, ?, ?)", id, stage, f.Expires, sealed)
+ if err != nil {
+ return ErrStorage
+ }
+ return nil
+}
+
+// load deletes a presented row before interpreting it when consume is true.
+// Callers commit refusals, so a rejected conflict or mixed-up browser cannot
+// reuse its proof. Storage failures roll back and never install a mapping.
+func (s *Store) load(ctx context.Context, tx *sql.Tx, stage, id string, high int64, consume bool) (flow, error) {
+ var raw []byte
+ var expires int64
+ err := tx.QueryRowContext(ctx, "SELECT expires_at, payload FROM docs_link_flows WHERE id_hash = ? AND stage = ?", id, stage).Scan(&expires, &raw)
+ if errors.Is(err, sql.ErrNoRows) {
+ return flow{}, ErrRefused
+ }
+ if err != nil {
+ return flow{}, ErrStorage
+ }
+ if consume {
+ if _, err := tx.ExecContext(ctx, "DELETE FROM docs_link_flows WHERE id_hash = ? AND stage = ?", id, stage); err != nil {
+ return flow{}, ErrStorage
+ }
+ }
+ if len(raw) > 8192 || expires <= high {
+ return flow{}, ErrRefused
+ }
+ opened, err := s.seal.Open(s.purpose(stage, id), raw)
+ if err != nil {
+ return flow{}, ErrRefused
+ }
+ var f flow
+ dec := json.NewDecoder(bytes.NewReader(opened))
+ dec.DisallowUnknownFields()
+ if dec.Decode(&f) != nil || dec.Decode(new(any)) != io.EOF {
+ return flow{}, ErrRefused
+ }
+ if f.Version != 1 || f.Expires != expires || f.Issuer != s.issuer || !handoff.SafePath(f.ReturnPath) {
+ return flow{}, ErrRefused
+ }
+ return f, nil
+}
+func (s *Store) matches(f flow, local Local, now, high int64) bool {
+ return validLocal(local, now, high) && f.PersonID == local.PersonID && f.SessionHash == hash(local.SessionToken) && f.LocalAuth == local.AuthTime.Unix() && fresh(f.LocalAuth, now, high)
+}
+func personExists(ctx context.Context, tx *sql.Tx, id int64) (bool, error) {
+ var found int
+ err := tx.QueryRowContext(ctx, "SELECT 1 FROM people WHERE id = ? AND email <> ''", id).Scan(&found)
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+ if err != nil {
+ return false, ErrStorage
+ }
+ return true, nil
+}
+
+// Start is called only by an explicit same-origin local-account link POST.
+// The returned secret belongs in a host-only Secure/HttpOnly/Lax cookie.
+func (s *Store) Start(ctx context.Context, local Local, returnPath string, now time.Time) (string, error) {
+ tx, high, err := s.begin(ctx, now)
+ if err != nil {
+ return "", err
+ }
+ defer tx.Rollback()
+ if err = sweep(ctx, tx, high); err != nil {
+ return "", err
+ }
+ if !validLocal(local, now.Unix(), high) {
+ return "", refuse(tx)
+ }
+ exists, err := personExists(ctx, tx, local.PersonID)
+ if err != nil {
+ return "", err
+ }
+ if !exists {
+ return "", refuse(tx)
+ }
+ var count int
+ if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM docs_link_flows").Scan(&count); err != nil {
+ return "", ErrStorage
+ }
+ if count >= maxFlows {
+ return "", refuse(tx)
+ }
+ secret, err := newSecret()
+ if err != nil {
+ return "", err
+ }
+ expires := min(now.Unix()+maxAge, local.AuthTime.Unix()+maxAge)
+ if expires <= high {
+ return "", refuse(tx)
+ }
+ f := flow{Version: 1, PersonID: local.PersonID, SessionHash: hash(local.SessionToken), LocalAuth: local.AuthTime.Unix(), Issuer: s.issuer, ReturnPath: safePath(returnPath), Expires: expires}
+ if err := s.put(ctx, tx, "init", hash(secret), f); err != nil {
+ return "", err
+ }
+ if err := commit(tx); err != nil {
+ return "", err
+ }
+ return secret, nil
+}
+
+// BindNonce is the shared consumer's start hook. It accepts one generated
+// nonce exactly once, before that consumer sets its cookie or redirects.
+func (s *Store) BindNonce(ctx context.Context, secret string, local Local, nonce string, now time.Time) error {
+ id, ok := secretHash(secret)
+ if !ok {
+ return ErrRefused
+ }
+ tx, high, err := s.begin(ctx, now)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ f, err := s.load(ctx, tx, "init", id, high, true)
+ if errors.Is(err, ErrRefused) {
+ return refuse(tx)
+ }
+ if err != nil {
+ return err
+ }
+ if !s.matches(f, local, now.Unix(), high) || !handoff.ValidNonce(nonce) || f.NonceHash != "" {
+ return refuse(tx)
+ }
+ f.NonceHash = hash(nonce)
+ if err := s.put(ctx, tx, "init", id, f); err != nil {
+ return err
+ }
+ return commit(tx)
+}
+
+// Complete consumes the initiation after the shared consumer has consumed and
+// verified the Home assertion. It keeps the local Docs session intact.
+func (s *Store) Complete(ctx context.Context, secret string, local Local, home Home, now time.Time) (string, error) {
+ id, ok := secretHash(secret)
+ if !ok {
+ return "", ErrRefused
+ }
+ tx, high, err := s.begin(ctx, now)
+ if err != nil {
+ return "", err
+ }
+ defer tx.Rollback()
+ f, err := s.load(ctx, tx, "init", id, high, true)
+ if errors.Is(err, ErrRefused) {
+ return "", refuse(tx)
+ }
+ if err != nil {
+ return "", err
+ }
+ if !s.matches(f, local, now.Unix(), high) || !handoff.ValidNonce(home.Nonce) || f.NonceHash != hash(home.Nonce) || home.Issuer != s.issuer || !validSubject(home.Subject) || !fresh(home.AuthTime.Unix(), now.Unix(), high) {
+ return "", refuse(tx)
+ }
+ exists, err := personExists(ctx, tx, local.PersonID)
+ if err != nil {
+ return "", err
+ }
+ if !exists {
+ return "", refuse(tx)
+ }
+ pending, err := newSecret()
+ if err != nil {
+ return "", err
+ }
+ f.Subject, f.HomeAuth = home.Subject, home.AuthTime.Unix()
+ f.Expires = min(f.Expires, f.HomeAuth+maxAge)
+ if f.Expires <= high {
+ return "", refuse(tx)
+ }
+ if err := s.put(ctx, tx, "pending", hash(pending), f); err != nil {
+ return "", err
+ }
+ if err := commit(tx); err != nil {
+ return "", err
+ }
+ return pending, nil
+}
+
+func validPending(f flow, now, high int64) bool {
+ return f.NonceHash != "" && validSubject(f.Subject) && fresh(f.HomeAuth, now, high)
+}
+func pendingView(f flow) Pending {
+ return Pending{PersonID: f.PersonID, ReturnPath: f.ReturnPath, HomeAuthTime: time.Unix(f.HomeAuth, 0)}
+}
+
+// Pending reads a still-valid continuation for its current local session.
+// Invalid presented state is consumed; reading valid state creates no link.
+func (s *Store) Pending(ctx context.Context, secret string, local Local, now time.Time) (Pending, error) {
+ id, ok := secretHash(secret)
+ if !ok {
+ return Pending{}, ErrRefused
+ }
+ tx, high, err := s.begin(ctx, now)
+ if err != nil {
+ return Pending{}, err
+ }
+ defer tx.Rollback()
+ f, err := s.load(ctx, tx, "pending", id, high, true)
+ if errors.Is(err, ErrRefused) {
+ return Pending{}, refuse(tx)
+ }
+ if err != nil {
+ return Pending{}, err
+ }
+ if !s.matches(f, local, now.Unix(), high) || !validPending(f, now.Unix(), high) {
+ return Pending{}, refuse(tx)
+ }
+ exists, err := personExists(ctx, tx, local.PersonID)
+ if err != nil {
+ return Pending{}, err
+ }
+ if !exists {
+ return Pending{}, refuse(tx)
+ }
+ if err := s.put(ctx, tx, "pending", id, f); err != nil {
+ return Pending{}, err
+ }
+ if err := commit(tx); err != nil {
+ return Pending{}, err
+ }
+ return pendingView(f), nil
+}
+
+// Confirm requires an explicit same-origin POST. The proof deletion and unique
+// mapping write commit together. A separate proof for the same mapping is
+// idempotent; a reused cookie or conflicting mapping is consumed and refused.
+func (s *Store) Confirm(ctx context.Context, secret string, local Local, now time.Time) (Pending, error) {
+ id, ok := secretHash(secret)
+ if !ok {
+ return Pending{}, ErrRefused
+ }
+ tx, high, err := s.begin(ctx, now)
+ if err != nil {
+ return Pending{}, err
+ }
+ defer tx.Rollback()
+ f, err := s.load(ctx, tx, "pending", id, high, true)
+ if errors.Is(err, ErrRefused) {
+ return Pending{}, refuse(tx)
+ }
+ if err != nil {
+ return Pending{}, err
+ }
+ if !s.matches(f, local, now.Unix(), high) || !validPending(f, now.Unix(), high) {
+ return Pending{}, refuse(tx)
+ }
+ exists, err := personExists(ctx, tx, local.PersonID)
+ if err != nil {
+ return Pending{}, err
+ }
+ if !exists {
+ return Pending{}, refuse(tx)
+ }
+ _, err = tx.ExecContext(ctx, "INSERT INTO docs_identity_links (issuer, subject, person_id) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", s.issuer, f.Subject, f.PersonID)
+ if err != nil {
+ return Pending{}, ErrStorage
+ }
+ var person int64
+ err = tx.QueryRowContext(ctx, "SELECT person_id FROM docs_identity_links WHERE issuer = ? AND subject = ?", s.issuer, f.Subject).Scan(&person)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Pending{}, refuse(tx)
+ }
+ if err != nil {
+ return Pending{}, ErrStorage
+ }
+ if person != f.PersonID {
+ return Pending{}, refuse(tx)
+ }
+ if err := commit(tx); err != nil {
+ return Pending{}, err
+ }
+ return pendingView(f), nil
+}
diff --git a/internal/docs/linkstore/migrations/0001_links.sql b/internal/docs/linkstore/migrations/0001_links.sql
new file mode 100644
index 0000000..8b19680
--- /dev/null
+++ b/internal/docs/linkstore/migrations/0001_links.sql
@@ -0,0 +1,19 @@
+CREATE TABLE docs_identity_links (
+ issuer TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ person_id INTEGER NOT NULL REFERENCES people(id),
+ PRIMARY KEY (issuer, subject),
+ UNIQUE (issuer, person_id)
+);
+CREATE TABLE docs_link_flows (
+ id_hash TEXT PRIMARY KEY NOT NULL,
+ stage TEXT NOT NULL CHECK (stage IN ('init', 'pending')),
+ expires_at INTEGER NOT NULL,
+ payload BLOB NOT NULL
+);
+CREATE INDEX docs_link_flows_expiry ON docs_link_flows(expires_at);
+CREATE TABLE docs_link_clock (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ high_water INTEGER NOT NULL
+);
+INSERT INTO docs_link_clock (id, high_water) VALUES (1, 0);
diff --git a/internal/docs/linkstore/store.go b/internal/docs/linkstore/store.go
new file mode 100644
index 0000000..d0e5f50
--- /dev/null
+++ b/internal/docs/linkstore/store.go
@@ -0,0 +1,198 @@
+// Package linkstore binds an existing Docs person to an independently verified
+// Home identity. It creates no people, sessions, membership or document grants.
+package linkstore
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "database/sql"
+ "embed"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "net/url"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/oficina/home/handoff/seal"
+ "github.com/carlosframework/rastrillo/migrate"
+)
+
+//go:embed migrations/*.sql
+var migrations embed.FS
+
+// Schema follows the Docs people schema in the application's BootSchema.
+var Schema = migrate.MustFromFS(migrations, "docs_identity_link")
+
+var (
+ ErrRefused = errors.New("docs link: refused")
+ ErrStorage = errors.New("docs link: storage unavailable")
+)
+
+const maxFlows = 10000
+const maxAge = 300
+
+// Local is supplied only after the caller verifies the actual local session
+// and its method-aware authentication time. Home assertions cannot supply it.
+// SessionToken is the actual cookie value, never a subject or a minted alias.
+type Local struct {
+ PersonID int64
+ SessionToken string
+ AuthTime time.Time
+}
+
+// Home contains already verified, consumed shared-handoff claims. The caller
+// must not construct it from query parameters or an unverified assertion.
+type Home struct {
+ Issuer, Subject, Nonce string
+ AuthTime time.Time
+}
+
+// Pending is safe server-side continuation state, not an authentication token.
+type Pending struct {
+ PersonID int64
+ ReturnPath string
+ HomeAuthTime time.Time
+}
+
+type Store struct {
+ db *sql.DB
+ seal *seal.Sealer
+ origin, issuer string
+}
+
+// New uses a dedicated handoff/storage DEK, never the document content key.
+// Apply Schema first, then call Sweep at boot before accepting requests.
+func New(db *sql.DB, dek []byte, origin, issuer string) (*Store, error) {
+ if db == nil || !validOrigin(origin) || !validOrigin(issuer) {
+ return nil, ErrRefused
+ }
+ sealer, err := seal.New(dek, "oficina/docs")
+ if err != nil {
+ return nil, ErrRefused
+ }
+ return &Store{db: db, seal: sealer, origin: origin, issuer: issuer}, nil
+}
+
+func validOrigin(v string) bool {
+ u, err := url.Parse(v)
+ return err == nil && len(v) <= 2048 && u.Scheme == "https" && u.Host != "" && u.Hostname() != "" && u.User == nil && u.Path == "" && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && u.Opaque == "" && u.String() == v && !strings.ContainsAny(v, "\\\r\n\t ")
+}
+
+func hash(v string) string { sum := sha256.Sum256([]byte(v)); return hex.EncodeToString(sum[:]) }
+func secretHash(v string) (string, bool) {
+ if len(v) != 43 {
+ return "", false
+ }
+ b, err := base64.RawURLEncoding.Strict().DecodeString(v)
+ return hash(v), err == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == v
+}
+func newSecret() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", ErrStorage
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+func fresh(at int64, now, high int64) bool { return at > 0 && at <= now && high-at <= maxAge }
+func validLocal(local Local, now, high int64) bool {
+ return local.PersonID > 0 && local.SessionToken != "" && len(local.SessionToken) <= 4096 && fresh(local.AuthTime.Unix(), now, high)
+}
+func validSubject(v string) bool {
+ if v == "" || len(v) > 512 || !utf8.ValidString(v) {
+ return false
+ }
+ for _, r := range v {
+ if r <= 32 || r == 127 {
+ return false
+ }
+ }
+ return true
+}
+
+// begin obtains the write lock before any reads, including capacity checks.
+// A persisted clock prevents stale evidence becoming fresh after a restart or
+// a clock rollback. Match the shared handoff's 30-second rollback tolerance.
+func (s *Store) begin(ctx context.Context, now time.Time) (*sql.Tx, int64, error) {
+ if now.Unix() <= 0 {
+ return nil, 0, ErrRefused
+ }
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return nil, 0, ErrStorage
+ }
+ if _, err = tx.ExecContext(ctx, "UPDATE docs_link_clock SET high_water = MAX(high_water, ?) WHERE id = 1", now.Unix()); err != nil {
+ tx.Rollback()
+ return nil, 0, ErrStorage
+ }
+ var high int64
+ if err = tx.QueryRowContext(ctx, "SELECT high_water FROM docs_link_clock WHERE id = 1").Scan(&high); err != nil {
+ tx.Rollback()
+ return nil, 0, ErrStorage
+ }
+ if now.Unix() < high-30 {
+ tx.Rollback()
+ return nil, 0, ErrRefused
+ }
+ return tx, high, nil
+}
+func refuse(tx *sql.Tx) error {
+ if err := tx.Commit(); err != nil {
+ return ErrStorage
+ }
+ return ErrRefused
+}
+func commit(tx *sql.Tx) error {
+ if err := tx.Commit(); err != nil {
+ return ErrStorage
+ }
+ return nil
+}
+func sweep(ctx context.Context, tx *sql.Tx, high int64) error {
+ if _, err := tx.ExecContext(ctx, "DELETE FROM docs_link_flows WHERE expires_at <= ?", high); err != nil {
+ return ErrStorage
+ }
+ return nil
+}
+func (s *Store) Sweep(ctx context.Context, now time.Time) error {
+ tx, high, err := s.begin(ctx, now)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if err := sweep(ctx, tx, high); err != nil {
+ return err
+ }
+ return commit(tx)
+}
+
+// Resolve reads the current existing person, never EnsurePerson or a cached
+// address. A deleted or unlinked person is refused without creating anything.
+func (s *Store) Resolve(ctx context.Context, issuer, subject string) (int64, string, error) {
+ if issuer != s.issuer || !validSubject(subject) {
+ return 0, "", ErrRefused
+ }
+ var id int64
+ var address string
+ err := s.db.QueryRowContext(ctx, `SELECT p.id, p.email FROM docs_identity_links l JOIN people p ON p.id = l.person_id WHERE l.issuer = ? AND l.subject = ?`, issuer, subject).Scan(&id, &address)
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, "", ErrRefused
+ }
+ if err != nil {
+ return 0, "", ErrStorage
+ }
+ if id <= 0 || address == "" {
+ return 0, "", ErrRefused
+ }
+ return id, address, nil
+}
+
+func safePath(v string) string {
+ if handoff.SafePath(v) {
+ return v
+ }
+ return "/"
+}
diff --git a/internal/docs/linkstore/store_test.go b/internal/docs/linkstore/store_test.go
new file mode 100644
index 0000000..0faf240
--- /dev/null
+++ b/internal/docs/linkstore/store_test.go
@@ -0,0 +1,534 @@
+package linkstore
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/base64"
+ "errors"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+var ctx = context.Background()
+var testNow = time.Unix(1788865200, 0)
+
+const origin = "https://test.docs.oficina.works"
+const issuer = "https://id.oficina.works"
+
+func fixture(t *testing.T) (*Store, string) {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "docs.db")
+ d, err := sql.Open("sqlite", (&url.URL{Scheme: "file", Path: path}).String()+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { d.Close() })
+ _, err = d.Exec(`CREATE TABLE people (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE); INSERT INTO people VALUES (1,'one@example.com'),(2,'two@example.com'); CREATE TABLE old_content (owner_id INTEGER, bytes BLOB); INSERT INTO old_content VALUES (1,x'00010203'),(2,x'04050607')`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ migration, err := migrations.ReadFile("migrations/0001_links.sql")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := d.Exec(string(migration)); err != nil {
+ t.Fatal(err)
+ }
+ s, err := New(d, bytes.Repeat([]byte{42}, 32), origin, issuer)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Sweep(ctx, testNow); err != nil {
+ t.Fatal(err)
+ }
+ return s, path
+}
+func local(id int64) Local {
+ return Local{PersonID: id, SessionToken: strings.Repeat(string(rune('a'+id)), 43), AuthTime: testNow.Add(-time.Minute)}
+}
+func nonce(n byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{n}, 16)) }
+func start(t *testing.T, s *Store, l Local, n string) string {
+ t.Helper()
+ secret, err := s.Start(ctx, l, "/d/private-document?view=reading", testNow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.BindNonce(ctx, secret, l, n, testNow); err != nil {
+ t.Fatal(err)
+ }
+ return secret
+}
+func complete(t *testing.T, s *Store, l Local, subject string, n byte) string {
+ t.Helper()
+ secret := start(t, s, l, nonce(n))
+ pending, err := s.Complete(ctx, secret, l, Home{Issuer: issuer, Subject: subject, Nonce: nonce(n), AuthTime: testNow.Add(-30 * time.Second)}, testNow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return pending
+}
+func refused(t *testing.T, err error) {
+ t.Helper()
+ if !errors.Is(err, ErrRefused) {
+ t.Fatalf("error = %v, want generic refusal", err)
+ }
+}
+func flowCount(t *testing.T, s *Store) int {
+ t.Helper()
+ var count int
+ if err := s.db.QueryRow("SELECT COUNT(*) FROM docs_link_flows").Scan(&count); err != nil {
+ t.Fatal(err)
+ }
+ return count
+}
+
+func TestExplicitLinkPreservesPeopleAndContent(t *testing.T) {
+ s, _ := fixture(t)
+ for _, id := range []int64{1, 2} {
+ l := local(id)
+ subject := "opaque-home-" + string(rune('a'+id))
+ cookie := complete(t, s, l, subject, byte(id))
+ p, err := s.Pending(ctx, cookie, l, testNow)
+ if err != nil || p.PersonID != id || p.ReturnPath != "/d/private-document?view=reading" {
+ t.Fatalf("pending=%+v error=%v", p, err)
+ }
+ _, _, err = s.Resolve(ctx, issuer, subject)
+ refused(t, err)
+ got, err := s.Confirm(ctx, cookie, l, testNow)
+ if err != nil || got.PersonID != id {
+ t.Fatalf("confirm=%+v error=%v", got, err)
+ }
+ linked, address, err := s.Resolve(ctx, issuer, subject)
+ if err != nil || linked != id || address == "" {
+ t.Fatalf("resolve=%d,%q,%v", linked, address, err)
+ }
+ _, err = s.Confirm(ctx, cookie, l, testNow)
+ refused(t, err)
+ }
+ var count int
+ if err := s.db.QueryRow("SELECT COUNT(*) FROM people").Scan(&count); err != nil || count != 2 {
+ t.Fatalf("people changed: %d %v", count, err)
+ }
+ var content string
+ if err := s.db.QueryRow("SELECT group_concat(hex(bytes), ',') FROM old_content ORDER BY owner_id").Scan(&content); err != nil || content != "00010203,04050607" {
+ t.Fatalf("legacy content changed: %q %v", content, err)
+ }
+ if _, err := s.db.Exec("UPDATE people SET email = 'new@example.com' WHERE id = 1"); err != nil {
+ t.Fatal(err)
+ }
+ _, address, err := s.Resolve(ctx, issuer, "opaque-home-b")
+ if err != nil || address != "new@example.com" {
+ t.Fatalf("stale address %q %v", address, err)
+ }
+ _, _, err = s.Resolve(ctx, "https://other.example", "opaque-home-b")
+ refused(t, err)
+ if flowCount(t, s) != 0 {
+ t.Fatal("consumed rows remain")
+ }
+}
+
+func TestInitiationAndCompletionBindingsBurnFailures(t *testing.T) {
+ for _, which := range []string{"missing-init", "unbound-nonce", "nonce", "person", "session", "local-auth", "issuer", "home-stale", "home-future", "local-stale", "local-future"} {
+ t.Run(which, func(t *testing.T) {
+ s, _ := fixture(t)
+ l := local(1)
+ n := nonce(1)
+ secret, err := s.Start(ctx, l, "/", testNow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if which != "unbound-nonce" {
+ if err := s.BindNonce(ctx, secret, l, n, testNow); err != nil {
+ t.Fatal(err)
+ }
+ }
+ home := Home{Issuer: issuer, Subject: "opaque", Nonce: n, AuthTime: testNow}
+ switch which {
+ case "missing-init":
+ secret = base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{80}, 32))
+ case "nonce":
+ home.Nonce = nonce(2)
+ case "person":
+ l.PersonID = 2
+ case "session":
+ l.SessionToken = "different-actual-cookie"
+ case "local-auth":
+ l.AuthTime = testNow
+ case "issuer":
+ home.Issuer = "https://evil.example"
+ case "home-stale":
+ home.AuthTime = testNow.Add(-301 * time.Second)
+ case "home-future":
+ home.AuthTime = testNow.Add(time.Second)
+ case "local-stale":
+ l.AuthTime = testNow.Add(-301 * time.Second)
+ case "local-future":
+ l.AuthTime = testNow.Add(time.Second)
+ }
+ got, err := s.Complete(ctx, secret, l, home, testNow)
+ refused(t, err)
+ if got != "" {
+ t.Fatal("rejected completion returned cookie")
+ }
+ _, err = s.Complete(ctx, secret, local(1), Home{Issuer: issuer, Subject: "opaque", Nonce: n, AuthTime: testNow}, testNow)
+ refused(t, err)
+ if which != "missing-init" && flowCount(t, s) != 0 {
+ t.Fatal("failed initiation was reusable")
+ }
+ })
+ }
+}
+
+func TestBindNonceOnceAndExactLocalSession(t *testing.T) {
+ for _, which := range []string{"duplicate", "person", "session", "invalid-nonce"} {
+ t.Run(which, func(t *testing.T) {
+ s, _ := fixture(t)
+ l := local(1)
+ secret, err := s.Start(ctx, l, "/", testNow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ n := nonce(1)
+ if which == "duplicate" {
+ if err := s.BindNonce(ctx, secret, l, n, testNow); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if which == "person" {
+ l.PersonID = 2
+ }
+ if which == "session" {
+ l.SessionToken = "changed"
+ }
+ if which == "invalid-nonce" {
+ n += "="
+ }
+ refused(t, s.BindNonce(ctx, secret, l, n, testNow))
+ refused(t, s.BindNonce(ctx, secret, local(1), nonce(2), testNow))
+ })
+ }
+}
+
+func TestConfirmationFreshnessAndLocalBinding(t *testing.T) {
+ for _, which := range []string{"person", "session", "auth-time", "expired", "future-after-rollback"} {
+ t.Run(which, func(t *testing.T) {
+ s, _ := fixture(t)
+ l := local(1)
+ cookie := complete(t, s, l, "opaque", 1)
+ now := testNow
+ switch which {
+ case "person":
+ l.PersonID = 2
+ case "session":
+ l.SessionToken = "new-cookie"
+ case "auth-time":
+ l.AuthTime = testNow
+ case "expired":
+ now = now.Add(241 * time.Second)
+ case "future-after-rollback":
+ // Complete a proof with a genuinely current Home gesture, then roll back
+ // within the accepted clock tolerance. It must not earn future credit.
+ init := start(t, s, l, nonce(2))
+ var err error
+ cookie, err = s.Complete(ctx, init, l, Home{Issuer: issuer, Subject: "opaque", Nonce: nonce(2), AuthTime: testNow}, testNow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now = now.Add(-time.Second)
+ }
+ _, err := s.Confirm(ctx, cookie, l, now)
+ refused(t, err)
+ _, err = s.Confirm(ctx, cookie, local(1), now)
+ refused(t, err)
+ _, _, err = s.Resolve(ctx, issuer, "opaque")
+ refused(t, err)
+ })
+ }
+}
+
+func TestConflictConsumedWithoutReplacingEitherMapping(t *testing.T) {
+ s, _ := fixture(t)
+ l1, l2 := local(1), local(2)
+ first := complete(t, s, l1, "subject-one", 1)
+ if _, err := s.Confirm(ctx, first, l1, testNow); err != nil {
+ t.Fatal(err)
+ }
+ identical := complete(t, s, l1, "subject-one", 2)
+ if _, err := s.Confirm(ctx, identical, l1, testNow); err != nil {
+ t.Fatal("separate identical proof should be idempotent", err)
+ }
+ for _, tc := range []struct {
+ local Local
+ sub string
+ }{{l2, "subject-one"}, {l1, "subject-two"}} {
+ cookie := complete(t, s, tc.local, tc.sub, 3)
+ _, err := s.Confirm(ctx, cookie, tc.local, testNow)
+ refused(t, err)
+ // Remove the competing mapping only in the fixture: an unconsumed failed
+ // proof would now succeed, making this a direct replay regression test.
+ if _, err := s.db.Exec("DELETE FROM docs_identity_links"); err != nil {
+ t.Fatal(err)
+ }
+ _, err = s.Confirm(ctx, cookie, tc.local, testNow)
+ refused(t, err)
+ if _, err := s.db.Exec("INSERT INTO docs_identity_links VALUES (?,?,?)", issuer, "subject-one", 1); err != nil {
+ t.Fatal(err)
+ }
+ }
+ id, _, err := s.Resolve(ctx, issuer, "subject-one")
+ if err != nil || id != 1 {
+ t.Fatalf("mapping replaced: %d %v", id, err)
+ }
+}
+
+func TestConcurrentConfirmationAndCapacity(t *testing.T) {
+ s, _ := fixture(t)
+ l := local(1)
+ cookie := complete(t, s, l, "subject", 1)
+ var winners atomic.Int32
+ var wg sync.WaitGroup
+ for i := 0; i < 12; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _, err := s.Confirm(ctx, cookie, l, testNow)
+ if err == nil {
+ winners.Add(1)
+ } else if !errors.Is(err, ErrRefused) {
+ t.Errorf("unexpected concurrent failure: %v", err)
+ }
+ }()
+ }
+ wg.Wait()
+ if winners.Load() != 1 {
+ t.Fatalf("winners=%d", winners.Load())
+ }
+ _, err := s.db.Exec(`WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x < 9999) INSERT INTO docs_link_flows SELECT printf('%064d',x),'init',?,x'00' FROM n`, testNow.Unix()+300)
+ if err != nil {
+ t.Fatal(err)
+ }
+ winners.Store(0)
+ for i := 0; i < 8; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _, err := s.Start(ctx, l, "/", testNow)
+ if err == nil {
+ winners.Add(1)
+ } else if !errors.Is(err, ErrRefused) {
+ t.Errorf("unexpected capacity failure: %v", err)
+ }
+ }()
+ }
+ wg.Wait()
+ if winners.Load() != 1 || flowCount(t, s) != 10000 {
+ t.Fatalf("capacity winners=%d count=%d", winners.Load(), flowCount(t, s))
+ }
+ if err := s.Sweep(ctx, testNow.Add(301*time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ if flowCount(t, s) != 0 {
+ t.Fatal("expired rows survived sweep")
+ }
+}
+
+func TestSealedPendingAndTamperRefusal(t *testing.T) {
+ for _, which := range []string{"ciphertext", "row", "origin", "expiry", "key"} {
+ t.Run(which, func(t *testing.T) {
+ s, _ := fixture(t)
+ l := local(1)
+ cookie := complete(t, s, l, "private-subject", 1)
+ switch which {
+ case "ciphertext":
+ if _, err := s.db.Exec("UPDATE docs_link_flows SET payload = zeroblob(length(payload))"); err != nil {
+ t.Fatal(err)
+ }
+ case "row":
+ replacement, _ := newSecret()
+ if _, err := s.db.Exec("UPDATE docs_link_flows SET id_hash = ?", hash(replacement)); err != nil {
+ t.Fatal(err)
+ }
+ cookie = replacement
+ case "origin":
+ s.origin = "https://other.docs.oficina.works"
+ case "expiry":
+ if _, err := s.db.Exec("UPDATE docs_link_flows SET expires_at = expires_at + 300"); err != nil {
+ t.Fatal(err)
+ }
+ case "key":
+ var err error
+ s, err = New(s.db, bytes.Repeat([]byte{43}, 32), origin, issuer)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ _, err := s.Confirm(ctx, cookie, l, testNow)
+ refused(t, err)
+ if flowCount(t, s) != 0 {
+ t.Fatal("tampered row remains reusable")
+ }
+ })
+ }
+}
+
+func TestNoPendingSecretsInSQLiteOrWAL(t *testing.T) {
+ s, path := fixture(t)
+ l := local(1)
+ n := nonce(8)
+ init := start(t, s, l, n)
+ cookie, err := s.Complete(ctx, init, l, Home{Issuer: issuer, Subject: "private-subject-should-be-sealed", Nonce: n, AuthTime: testNow}, testNow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, file := range []string{path, path + "-wal"} {
+ raw, err := os.ReadFile(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, secret := range []string{init, cookie, l.SessionToken, n, "private-subject-should-be-sealed", "/d/private-document?view=reading"} {
+ if bytes.Contains(raw, []byte(secret)) {
+ t.Fatalf("plaintext in %s", filepath.Base(file))
+ }
+ }
+ }
+ reopened, err := New(s.db, bytes.Repeat([]byte{42}, 32), origin, issuer)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := reopened.Confirm(ctx, cookie, l, testNow); err != nil {
+ t.Fatal("sealed pending must survive store restart", err)
+ }
+}
+
+func TestClockHighWaterPreventsRevivedAuthentication(t *testing.T) {
+ s, _ := fixture(t)
+ l := local(1)
+ cookie := complete(t, s, l, "subject", 1)
+ if err := s.Sweep(ctx, testNow.Add(241*time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ _, err := s.Confirm(ctx, cookie, l, testNow.Add(220*time.Second))
+ refused(t, err)
+ if err := s.Sweep(ctx, testNow); !errors.Is(err, ErrRefused) {
+ t.Fatalf("large clock rollback=%v", err)
+ }
+}
+
+func TestSafePathsAndInvalidInputs(t *testing.T) {
+ for _, path := range []string{"", "https://evil.example", "//evil.example", "/auth/finish", "/%2fhost", "/d/../auth/finish", "/d\\evil", "/d/x\n"} {
+ t.Run(path, func(t *testing.T) {
+ s, _ := fixture(t)
+ l := local(1)
+ init, err := s.Start(ctx, l, path, testNow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.BindNonce(ctx, init, l, nonce(1), testNow); err != nil {
+ t.Fatal(err)
+ }
+ cookie, err := s.Complete(ctx, init, l, Home{Issuer: issuer, Subject: "subject", Nonce: nonce(1), AuthTime: testNow}, testNow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ p, err := s.Pending(ctx, cookie, l, testNow)
+ if err != nil || p.ReturnPath != "/" {
+ t.Fatalf("unsafe return=%q %v", p.ReturnPath, err)
+ }
+ })
+ }
+ s, _ := fixture(t)
+ for _, l := range []Local{{}, {PersonID: 99, SessionToken: "actual", AuthTime: testNow}, {PersonID: 1, SessionToken: "actual", AuthTime: testNow.Add(time.Second)}, {PersonID: 1, SessionToken: "actual", AuthTime: testNow.Add(-301 * time.Second)}} {
+ _, err := s.Start(ctx, l, "/", testNow)
+ refused(t, err)
+ }
+ for _, cookie := range []string{"", "short", strings.Repeat("A", 43) + "=", strings.Repeat("A", 42) + "B"} {
+ _, err := s.Confirm(ctx, cookie, local(1), testNow)
+ refused(t, err)
+ }
+}
+
+func TestMappingStorageFailureRollsBackProofConsumption(t *testing.T) {
+ s, _ := fixture(t)
+ l := local(1)
+ cookie := complete(t, s, l, "subject", 1)
+ if _, err := s.db.Exec(`CREATE TRIGGER block_link BEFORE INSERT ON docs_identity_links BEGIN SELECT RAISE(ABORT, 'private internal storage detail'); END`); err != nil {
+ t.Fatal(err)
+ }
+ _, err := s.Confirm(ctx, cookie, l, testNow)
+ if !errors.Is(err, ErrStorage) || strings.Contains(err.Error(), "private") {
+ t.Fatalf("storage error = %v", err)
+ }
+ if flowCount(t, s) != 1 {
+ t.Fatal("storage failure consumed proof without mapping")
+ }
+ if _, err := s.db.Exec("DROP TRIGGER block_link"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Confirm(ctx, cookie, l, testNow); err != nil {
+ t.Fatal("retry after storage recovery", err)
+ }
+}
+
+func TestConcurrentConflictingSubjectsCannotReplacePersonLink(t *testing.T) {
+ s, _ := fixture(t)
+ l := local(1)
+ a, b := complete(t, s, l, "subject-a", 1), complete(t, s, l, "subject-b", 2)
+ var winners atomic.Int32
+ var wg sync.WaitGroup
+ for _, cookie := range []string{a, b} {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _, err := s.Confirm(ctx, cookie, l, testNow)
+ if err == nil {
+ winners.Add(1)
+ } else if !errors.Is(err, ErrRefused) {
+ t.Errorf("conflicting confirmation: %v", err)
+ }
+ }()
+ }
+ wg.Wait()
+ if winners.Load() != 1 || flowCount(t, s) != 0 {
+ t.Fatalf("conflict winners=%d remaining=%d", winners.Load(), flowCount(t, s))
+ }
+ for _, cookie := range []string{a, b} {
+ _, err := s.Confirm(ctx, cookie, l, testNow)
+ refused(t, err)
+ }
+}
+
+func TestPendingReadConsumesChangedSession(t *testing.T) {
+ s, _ := fixture(t)
+ l := local(1)
+ cookie := complete(t, s, l, "subject", 1)
+ changed := l
+ changed.SessionToken = "rotated-cookie"
+ _, err := s.Pending(ctx, cookie, changed, testNow)
+ refused(t, err)
+ _, err = s.Confirm(ctx, cookie, l, testNow)
+ refused(t, err)
+}
+
+func TestFreshnessBoundaryProducesNoExpiredNewRows(t *testing.T) {
+ s, _ := fixture(t)
+ l := local(1)
+ l.AuthTime = testNow.Add(-300 * time.Second)
+ _, err := s.Start(ctx, l, "/", testNow)
+ refused(t, err)
+ l = local(1)
+ init := start(t, s, l, nonce(1))
+ _, err = s.Complete(ctx, init, l, Home{Issuer: issuer, Subject: "subject", Nonce: nonce(1), AuthTime: testNow.Add(-300 * time.Second)}, testNow)
+ refused(t, err)
+ if flowCount(t, s) != 0 {
+ t.Fatal("expired state minted at exact five-minute boundary")
+ }
+}