oficina / docs Public

Resolve Home sessions through existing Docs identities

Paul Campbell pushed by paul@keymail.dev a40f11047786e9e5f63bbb3fab5eba1c28681406 parent 8a12672
4 files changed, +450 −38
  • internal/docs/auth.go +21 −3
  • internal/docs/handlers.go +10 −35
  • internal/docs/identity.go +134 −0
  • internal/docs/identity_test.go +285 −0

CI passed — run details

diff --git a/internal/docs/auth.go b/internal/docs/auth.go
index 38dd9d2..c783109 100644
--- a/internal/docs/auth.go
+++ b/internal/docs/auth.go
@@ -30,6 +30,7 @@ import (
rauth "github.com/carlosframework/rastrillo/auth"
"github.com/carlosframework/rastrillo/mail"
+ "github.com/carlosframework/rastrillo/sessions"
"github.com/carlosframework/rastrillo"
)
@@ -391,10 +392,27 @@ func gateBegin(next http.HandlerFunc, list allowlist, signinPath string) http.Ha
// with no identity it does nothing and lets RequireSession's own redirect
// stand.
func requireAdmission(list allowlist, signinPath string) func(http.Handler) http.Handler {
- return requireAdmissionOf(list, signinPath, func(r *http.Request) (string, bool) {
- id, ok := rauth.From(r)
- return id.Address, ok
+ admission := requireAdmissionOf(list, signinPath, func(r *http.Request) (string, bool) {
+ if id, ok := currentIdentity(r); ok {
+ return id.Actor.Subject, true
+ }
+ s, ok := sessions.Current(r)
+ return s.Subject, ok && s.Method != homeAssertionMethod
})
+ return func(next http.Handler) http.Handler {
+ allowed := admission(next)
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Missing adapter wiring fails closed even if an opaque subject happens
+ // to match an allowed address. It must not take the legacy fallback.
+ if s, ok := sessions.Current(r); ok && s.Method == homeAssertionMethod {
+ if _, resolved := currentIdentity(r); !resolved {
+ refuseIdentity(w, r, signinPath)
+ return
+ }
+ }
+ allowed.ServeHTTP(w, r)
+ })
+ }
}
// requireAdmissionOf is requireAdmission with the identity lookup passed
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index 23877bb..6ea164d 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -14,7 +14,6 @@ import (
"amadan.net/oficina/estilo.git/savestatus"
"github.com/carlosframework/rastrillo"
- rauth "github.com/carlosframework/rastrillo/auth"
"github.com/carlosframework/docs/internal/core"
)
@@ -183,40 +182,16 @@ type docRow struct {
SharedNote string
}
-// owner resolves the signed-in person's Store.Person id for r —
-// deliberately auth.From(r).Address, never sessions.UserID: under the
-// magic-link plugin the session Subject is the verified email itself
-// (rastrillo SKILL.md §5), so UserID's ParseInt would fail and, worse,
-// a version of this code that dropped its ok return would silently
-// scope every query to person 0. Every handler below is mounted only
-// inside app.go's RequireSession group, so From always finds an
-// identity here in practice; the error return exists so a handler
-// answers 500 rather than panicking if that ever stops being true.
+// owner uses the linked local person ID for Home sessions and the existing
+// address-to-person mapping for standalone authentication.
func (h *handlers) owner(r *http.Request) (int64, error) {
- id, ok := rauth.From(r)
- if !ok {
- return 0, errors.New("docs: no signed-in identity on request")
- }
- return h.store.EnsurePerson(r.Context(), id.Address)
+ actor, err := requestActor(r, h.store)
+ return actor.PersonID, err
}
-// actor is owner plus the address, which is what a write needs.
-//
-// Ownership is keyed on the numeric person id; grants are keyed on the
-// address (0005_grants.sql says why). A write path has to check both — the
-// person might own the document, or hold a writer grant on it — so it needs
-// both halves of the same identity, and taking them from one place keeps them
-// from drifting apart at a call site.
+// actor supplies the same local identity to ownership and address grants.
func (h *handlers) actor(r *http.Request) (Actor, error) {
- id, ok := rauth.From(r)
- if !ok {
- return Actor{}, errors.New("docs: no signed-in identity on request")
- }
- person, err := h.store.EnsurePerson(r.Context(), id.Address)
- if err != nil {
- return Actor{}, err
- }
- return Actor{PersonID: person, Subject: id.Address}, nil
+ return requestActor(r, h.store)
}
// member reports whether the person on this request is a full member of
@@ -229,11 +204,11 @@ func (h *handlers) actor(r *http.Request) (Actor, error) {
// members — the guest was invited by one. Pretending POST /documents does
// not exist would be a lie told for no gain.
func (h *handlers) member(r *http.Request) bool {
- if h.isMember == nil {
- return true
+ id, err := requestActor(r, h.store)
+ if err != nil {
+ return false
}
- id, ok := rauth.From(r)
- return ok && h.isMember(id.Address)
+ return h.isMember == nil || h.isMember(id.Subject)
}
// list serves GET /: the document list, spec §6.4 and Paul's four
diff --git a/internal/docs/identity.go b/internal/docs/identity.go
new file mode 100644
index 0000000..6fdae35
--- /dev/null
+++ b/internal/docs/identity.go
@@ -0,0 +1,134 @@
+package docs
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/carlosframework/rastrillo"
+ "github.com/carlosframework/rastrillo/sessions"
+ "gorm.io/gorm"
+)
+
+const homeAssertionMethod = "home-assertion"
+
+var (
+ errIdentityRefused = errors.New("docs: identity refused")
+ errIdentityStorage = errors.New("docs: identity storage unavailable")
+)
+
+// requestIdentity keeps the verified session separate from the local person
+// and address that own documents and hold grants.
+type requestIdentity struct {
+ Actor Actor
+ Session sessions.Session
+}
+type identityContextKey struct{}
+
+func currentIdentity(r *http.Request) (requestIdentity, bool) {
+ id, ok := r.Context().Value(identityContextKey{}).(requestIdentity)
+ return id, ok
+}
+
+type identityResolver struct {
+ store *Store
+ issuer string
+ // Return zero for an unknown link. Errors must not contain identity values.
+ linkedPerson func(context.Context, string, string) (int64, error)
+}
+
+func (v identityResolver) resolve(r *http.Request) (requestIdentity, error) {
+ session, ok := sessions.Current(r)
+ if !ok || session.Subject == "" || v.store == nil {
+ return requestIdentity{}, errIdentityRefused
+ }
+ if session.Method == homeAssertionMethod {
+ if v.issuer == "" || v.linkedPerson == nil {
+ return requestIdentity{}, errIdentityRefused
+ }
+ person, err := v.linkedPerson(r.Context(), v.issuer, session.Subject)
+ if errors.Is(err, errIdentityRefused) {
+ return requestIdentity{}, errIdentityRefused
+ }
+ if err != nil {
+ return requestIdentity{}, errIdentityStorage
+ }
+ if person <= 0 {
+ return requestIdentity{}, errIdentityRefused
+ }
+ var p Person
+ if err = v.store.db.WithContext(r.Context()).First(&p, person).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return requestIdentity{}, errIdentityRefused
+ }
+ return requestIdentity{}, errIdentityStorage
+ }
+ address := subjectOf(p.Email)
+ if address == "" {
+ return requestIdentity{}, errIdentityRefused
+ }
+ return requestIdentity{Actor: Actor{PersonID: p.ID, Subject: address}, Session: session}, nil
+ }
+ // The local authentication plugin verified this address. Preserve its
+ // existing get-or-create behavior, including opt-in sample seeding.
+ person, err := v.store.EnsurePerson(r.Context(), session.Subject)
+ if err != nil {
+ return requestIdentity{}, errIdentityStorage
+ }
+ return requestIdentity{Actor: Actor{PersonID: person, Subject: session.Subject}, Session: session}, nil
+}
+
+// middleware belongs after RequireSession and before admission. A verified
+// Home subject with no local link must never reach an app handler.
+func (v identityResolver) middleware(signinPath string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ id, err := v.resolve(r)
+ if err != nil {
+ if errors.Is(err, errIdentityStorage) {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable)
+ } else {
+ refuseIdentity(w, r, signinPath)
+ }
+ return
+ }
+ next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), identityContextKey{}, id)))
+ })
+ }
+}
+
+func refuseIdentity(w http.ResponseWriter, r *http.Request, signinPath string) {
+ if r.Method == http.MethodGet || r.Method == http.MethodHead {
+ http.Redirect(w, r, signinPath, http.StatusSeeOther)
+ return
+ }
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden)
+}
+
+// requestActor supports standalone routes without the suite middleware too.
+// The fallback resolver still refuses every Home session without a link.
+func requestActor(r *http.Request, store *Store) (Actor, error) {
+ if id, ok := currentIdentity(r); ok {
+ return id.Actor, nil
+ }
+ id, err := (identityResolver{store: store}).resolve(r)
+ return id.Actor, err
+}
+
+// freshLocalAuthentication accepts only an independent local verification.
+// Rastrillo stores zero AuthTime for completed magic links; only that method
+// can use the persisted session creation time as verification evidence.
+func freshLocalAuthentication(s sessions.Session, now time.Time) (time.Time, bool) {
+ if s.Subject == "" || (s.Method != "magiclink" && s.Method != "keymail") {
+ return time.Time{}, false
+ }
+ at := s.AuthTime
+ if at.IsZero() && s.Method == "magiclink" {
+ at = s.At
+ }
+ if at.IsZero() || at.After(now) || now.Sub(at) > 5*time.Minute {
+ return time.Time{}, false
+ }
+ return at, true
+}
diff --git a/internal/docs/identity_test.go b/internal/docs/identity_test.go
new file mode 100644
index 0000000..2e9bd62
--- /dev/null
+++ b/internal/docs/identity_test.go
@@ -0,0 +1,285 @@
+package docs
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/rastrillo/sessions"
+)
+
+func identityRequest(method, subject, authMethod string) *http.Request {
+ return sessions.WithSession(httptest.NewRequest(method, "https://docs.example/", nil), sessions.Session{Subject: subject, Method: authMethod})
+}
+
+func TestHomeIdentityNeverCreatesPersonOrSamples(t *testing.T) {
+ app := newSampleDocumentsInternalTestApp(t, "1")
+ store := app.Store()
+ h := &handlers{store: store}
+ for _, subject := range []string{"opaque_subject", "looks-like-local@example.com"} {
+ r := identityRequest("GET", subject, homeAssertionMethod)
+ if _, err := h.owner(r); err != errIdentityRefused {
+ t.Fatal("unconfigured Home identity reached legacy owner", err)
+ }
+ if _, err := h.actor(r); err != errIdentityRefused {
+ t.Fatal("unconfigured Home identity reached legacy actor", err)
+ }
+ if h.member(r) {
+ t.Fatal("unconfigured Home identity gained membership")
+ }
+ for _, id := range []int64{0, 999999} {
+ v := identityResolver{store: store, issuer: "https://id.example", linkedPerson: func(ctx context.Context, issuer, got string) (int64, error) {
+ if issuer != "https://id.example" || got != subject {
+ t.Fatal("issuer/subject changed")
+ }
+ return id, nil
+ }}
+ reached := false
+ rec := httptest.NewRecorder()
+ v.middleware("/signin")(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })).ServeHTTP(rec, r)
+ if reached || rec.Code != 303 {
+ t.Fatal("unknown Home identity reached handler")
+ }
+ }
+ }
+ var people, documents int64
+ if err := store.db.Model(&Person{}).Count(&people).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := store.db.Model(&Document{}).Count(&documents).Error; err != nil {
+ t.Fatal(err)
+ }
+ if people != 0 || documents != 0 {
+ t.Fatalf("Home path created people=%d documents=%d", people, documents)
+ }
+}
+
+func TestLinkedHomeIdentityPreservesOwnersAndAddressGrants(t *testing.T) {
+ app := newSampleDocumentsInternalTestApp(t, "0")
+ store := app.Store()
+ ctx := t.Context()
+ alice, err := store.EnsurePerson(ctx, "alice@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ bob, err := store.EnsurePerson(ctx, "bob@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ own, err := store.Create(ctx, alice, "Alice private")
+ if err != nil {
+ t.Fatal(err)
+ }
+ shared, err := store.Create(ctx, bob, "Bob shared")
+ if err != nil {
+ t.Fatal(err)
+ }
+ hidden, err := store.Create(ctx, bob, "Bob private")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = store.Grant(ctx, bob, shared.Sqid, "alice@example.com", RoleWriter); err != nil {
+ t.Fatal(err)
+ }
+ v := identityResolver{store: store, issuer: "https://id.example", linkedPerson: func(ctx context.Context, issuer, subject string) (int64, error) {
+ if issuer != "https://id.example" || subject != "opaque_alice" {
+ return 0, nil
+ }
+ return alice, nil
+ }}
+ memberAddresses := []string{}
+ h := &handlers{store: store, isMember: func(address string) bool {
+ memberAddresses = append(memberAddresses, address)
+ return address == "alice@example.com"
+ }}
+ reached := false
+ end := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ reached = true
+ a, err := h.actor(r)
+ if err != nil || a.PersonID != alice || a.Subject != "alice@example.com" {
+ t.Fatal("linked actor changed", a, err)
+ }
+ id, err := h.owner(r)
+ if err != nil || id != alice {
+ t.Fatal("linked owner changed", err)
+ }
+ if !h.member(r) {
+ t.Fatal("member did not use verified local address")
+ }
+ for _, d := range []*Document{own, shared} {
+ got, err := store.GetFor(ctx, a, d.Sqid)
+ if err != nil || got.ID != d.ID || !store.CanWrite(ctx, a, d.ID) {
+ t.Fatal("existing access lost", err)
+ }
+ }
+ if _, err := store.GetFor(ctx, a, hidden.Sqid); err == nil {
+ t.Fatal("link widened access")
+ }
+ if id, ok := currentIdentity(r); !ok || id.Session.Subject != "opaque_alice" {
+ t.Fatal("opaque identity not kept separate")
+ }
+ })
+ rec := httptest.NewRecorder()
+ v.middleware("/signin")(end).ServeHTTP(rec, identityRequest("GET", "opaque_alice", homeAssertionMethod))
+ if !reached || len(memberAddresses) != 1 {
+ t.Fatal("adapter not used")
+ }
+ var after Document
+ if err = store.db.First(&after, own.ID).Error; err != nil || after.OwnerID != alice {
+ t.Fatal("ownership rewritten")
+ }
+ grants, err := store.ListGrants(ctx, bob, shared.Sqid)
+ if err != nil || len(grants) != 1 || grants[0].Subject != "alice@example.com" {
+ t.Fatal("grant rewritten")
+ }
+ // Resolve the current local address on every request, not a saved copy.
+ if err = store.db.Model(&Person{}).Where("id=?", alice).Update("email", "renamed@example.com").Error; err != nil {
+ t.Fatal(err)
+ }
+ id, err := v.resolve(identityRequest("GET", "opaque_alice", homeAssertionMethod))
+ if err != nil || id.Actor.Subject != "renamed@example.com" {
+ t.Fatal("stale local address")
+ }
+}
+
+func TestHomeAdmissionRequiresAdapterAndCurrentAddress(t *testing.T) {
+ app := newSampleDocumentsInternalTestApp(t, "0")
+ store := app.Store()
+ person, err := store.EnsurePerson(t.Context(), "local@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ list, err := resolveAllowlist(Config{AllowedAddresses: "local@example.com"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ v := identityResolver{store: store, issuer: "https://id.example", linkedPerson: func(context.Context, string, string) (int64, error) { return person, nil }}
+ reached := false
+ next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })
+ for _, method := range []string{"GET", "POST"} {
+ reached = false
+ rec := httptest.NewRecorder()
+ requireAdmission(list, "/signin")(next).ServeHTTP(rec, identityRequest(method, "local@example.com", homeAssertionMethod))
+ if reached || (method == "GET" && rec.Code != 303) || (method == "POST" && rec.Code != 403) {
+ t.Fatal("opaque address bypassed adapter")
+ }
+ }
+ rec := httptest.NewRecorder()
+ v.middleware("/signin")(requireAdmission(list, "/signin")(next)).ServeHTTP(rec, identityRequest("GET", "opaque", homeAssertionMethod))
+ if !reached {
+ t.Fatal("resolved local address not admitted")
+ }
+ reached = false
+ list, err = resolveAllowlist(Config{AllowedAddresses: "someone-else@example.com"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec = httptest.NewRecorder()
+ v.middleware("/signin")(requireAdmission(list, "/signin")(next)).ServeHTTP(rec, identityRequest("POST", "opaque", homeAssertionMethod))
+ if reached || rec.Code != 403 {
+ t.Fatal("link bypassed changed admission")
+ }
+ v.linkedPerson = func(context.Context, string, string) (int64, error) { return 0, errors.New("fixture storage failure") }
+ rec = httptest.NewRecorder()
+ v.middleware("/signin")(next).ServeHTTP(rec, identityRequest("POST", "opaque", homeAssertionMethod))
+ if reached || rec.Code != 503 {
+ t.Fatal("storage failure became admission or permanent write refusal")
+ }
+}
+
+func TestLocalFreshnessUsesActualMethodAndVerificationTime(t *testing.T) {
+ now := time.Unix(1700000000, 0)
+ for _, tc := range []struct {
+ name, method string
+ auth, created time.Time
+ want bool
+ }{
+ {"magic original", "magiclink", now.Add(-time.Minute), now, true},
+ {"magic persisted creation", "magiclink", time.Time{}, now.Add(-time.Minute), true},
+ {"boundary", "magiclink", now.Add(-5 * time.Minute), now, true},
+ {"old verification fresh remint", "magiclink", now.Add(-301 * time.Second), now, false},
+ {"old persisted creation", "magiclink", time.Time{}, now.Add(-301 * time.Second), false},
+ {"future verification", "magiclink", now.Add(time.Second), now, false},
+ {"future creation", "magiclink", time.Time{}, now.Add(time.Second), false},
+ {"missing times", "magiclink", time.Time{}, time.Time{}, false},
+ {"keymail actual", "keymail", now.Add(-time.Minute), now, true},
+ {"keymail no original", "keymail", time.Time{}, now, false},
+ {"keymail stale", "keymail", now.Add(-301 * time.Second), now, false},
+ {"keymail future", "keymail", now.Add(time.Second), now, false},
+ {"home original", "home-assertion", now, now, false},
+ {"home remint", "home-assertion", time.Time{}, now, false},
+ {"other original", "password", now, now, false},
+ {"unknown remint", "", time.Time{}, now, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ got, ok := freshLocalAuthentication(sessions.Session{Subject: "local@example.com", Method: tc.method, AuthTime: tc.auth, At: tc.created}, now)
+ if ok != tc.want {
+ t.Fatal("wrong local freshness decision")
+ }
+ expected := tc.auth
+ if expected.IsZero() {
+ expected = tc.created
+ }
+ if ok && !got.Equal(expected) {
+ t.Fatal("verification time rewritten")
+ }
+ if !ok && !got.IsZero() {
+ t.Fatal("refused proof returned freshness")
+ }
+ })
+ }
+}
+
+func TestLocalMagicLinkUsesPersistedVerificationAndRotation(t *testing.T) {
+ app := newSampleDocumentsInternalTestApp(t, "0")
+ reader, err := sessions.New(sessions.Config{DB: app.db.Writer(), Origin: app.cfg.Origin})
+ if err != nil {
+ t.Fatal(err)
+ }
+ first := app.TestRequest(t, "local@example.com", "GET", "/", nil)
+ session, ok := reader.From(first)
+ if !ok || session.Method != "magiclink" || !session.AuthTime.IsZero() || session.At.IsZero() {
+ t.Fatal("real local magic-link session did not match fallback contract")
+ }
+ verified, ok := freshLocalAuthentication(session, time.Now())
+ if !ok || !verified.Equal(session.At) {
+ t.Fatal("actual completed magic link not fresh")
+ }
+ oldCookie, err := first.Cookie(reader.CookieName())
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Complete a second real local email token while presenting the first session.
+ link, err := app.TestSignIn(t, "local@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ verify := httptest.NewRequest("GET", link, nil)
+ verify.AddCookie(oldCookie)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, verify)
+ if rec.Code != 303 {
+ t.Fatal("local reverification failed", rec.Code)
+ }
+ second := httptest.NewRequest("GET", "/", nil)
+ for _, c := range rec.Result().Cookies() {
+ second.AddCookie(c)
+ }
+ if _, ok = reader.From(first); ok {
+ t.Fatal("reverification left old session usable")
+ }
+ next, ok := reader.From(second)
+ if !ok {
+ t.Fatal("rotated session missing")
+ }
+ if _, ok = freshLocalAuthentication(next, time.Now()); !ok {
+ t.Fatal("rotated real magic link not fresh")
+ }
+ newCookie, err := second.Cookie(reader.CookieName())
+ if err != nil || newCookie.Value == oldCookie.Value {
+ t.Fatal("actual browser binding did not rotate")
+ }
+}