M README.md => README.md +4 -2
@@ 43,8 43,10 @@ authorizer spares git.sr.ht a GraphQL call on every page load.
sentinel errors. No external dependencies.
- `gitx/` — bare-repo access over go-git: refs, ref-to-ref diffs, single-commit
diffs, and commit logs, all bounded by context timeouts and output-size caps.
-- `authz/` — cookie→identity and the git.sr.ht GraphQL authorizer with a short
- TTL cache.
+- `authz/` — the git.sr.ht GraphQL authorizer with a short TTL cache. Identity
+ is not here any more: the unified-login cookie is decoded by [sr-ht-ecore]'s
+ `login`, the instance's one copy of that decode, which — unlike the local one
+ it replaced — refuses a name that could not be a username.
- `web/` — chi router, handlers, Go templates, embedded static assets. Most of
the machinery around them is not here but in [sr-ht-ecore], wired up in
`web/server.go`: `chrome` for the page frame, `pages` for template discovery
M authz/authz_test.go => authz/authz_test.go +9 -0
@@ 6,6 6,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
+ "os"
"strings"
"sync/atomic"
"testing"
@@ 18,6 19,14 @@ import (
"sourcecraft.dev/bigbes/sr-ht-compare/core"
)
+// TestMain installs ecore's fixed test keyset into core-go's process-global
+// crypto, so the Internal authorization this package seals for git.sr.ht can be
+// opened again by the stub receiver below.
+func TestMain(m *testing.M) {
+ ecoretest.InitCrypto()
+ os.Exit(m.Run())
+}
+
// authNameFromRequest asserts the incoming request bears an "Internal <token>"
// Authorization header that decrypts to InternalAuth JSON, and returns the Name
// field (empty for anonymous). This mirrors what git.sr.ht's receiver does.
M authz/doc.go => authz/doc.go +7 -6
@@ 1,9 1,10 @@
-// Package authz answers two orthogonal questions for compare.sr.ht: who is
-// making a request, and what may they see. Identity is derived purely from the
-// SourceHut unified-login cookie (sr.ht.unified-login.v1), a Fernet token
-// encrypted with the instance [sr.ht] network-key; UsernameFromRequest decrypts
-// it and yields a bare username, or "" for an anonymous viewer. It never
-// rejects a request — an unreadable or absent cookie simply means anonymous.
+// Package authz answers one question for compare.sr.ht: what may a given viewer
+// see. Who the viewer is, is not this package's question any more — the
+// unified-login cookie is decoded by sr-ht-ecore/login, which every custom
+// service on the instance shares, and a handler reads the answer with
+// login.FromContext. The copy that used to live here decoded the same cookie
+// and did not validate the name it found, which is the whole reason that decode
+// is one package now.
//
// Authorization is delegated entirely to git.sr.ht over its internal GraphQL
// API: compare.sr.ht owns no user or repository data of its own, so there is no
D authz/identity.go => authz/identity.go +0 -63
@@ 1,63 0,0 @@
-package authz
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "strings"
-
- "sourcecraft.dev/bigbes/sr-ht-core/crypto"
-)
-
-// CookieName is the SourceHut unified-login session cookie. Its value is a
-// Fernet token encrypted with the instance [sr.ht] network-key.
-const CookieName = "sr.ht.unified-login.v1"
-
-// UsernameFromRequest extracts the authenticated username from the unified-login
-// cookie, or returns "" for an anonymous viewer. Any failure — missing cookie,
-// undecryptable token, malformed JSON — is treated as anonymous rather than an
-// error: this service never rejects a request on identity grounds, it only lets
-// git.sr.ht decide what an anonymous viewer may see.
-func UsernameFromRequest(r *http.Request) string {
- c, err := r.Cookie(CookieName)
- if err != nil {
- return ""
- }
- // InitCrypto must have run (server.New does it); the network-key here is
- // the same Fernet key meta.sr.ht used to seal the cookie.
- payload := crypto.DecryptWithoutExpiration([]byte(c.Value))
- if payload == nil {
- return ""
- }
- var claims struct {
- Name string `json:"name"`
- }
- if err := json.Unmarshal(payload, &claims); err != nil {
- return ""
- }
- // Cookies carry the bare username; strip a leading "~" defensively in case
- // a caller stored the canonical "~user" form.
- return strings.TrimPrefix(claims.Name, "~")
-}
-
-type ctxKey int
-
-const usernameKey ctxKey = iota
-
-// Middleware stores the cookie-derived username in the request context. It
-// never writes a 401: an anonymous viewer flows through with an empty username
-// and git.sr.ht enforces visibility downstream.
-func Middleware() func(http.Handler) http.Handler {
- return func(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- ctx := context.WithValue(r.Context(), usernameKey, UsernameFromRequest(r))
- next.ServeHTTP(w, r.WithContext(ctx))
- })
- }
-}
-
-// ForContext returns the username stored by Middleware, or "" if absent.
-func ForContext(ctx context.Context) string {
- username, _ := ctx.Value(usernameKey).(string)
- return username
-}
D authz/identity_test.go => authz/identity_test.go +0 -98
@@ 1,98 0,0 @@
-package authz
-
-import (
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "os"
- "testing"
-
- "sourcecraft.dev/bigbes/sr-ht-core/crypto"
- "sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest"
-)
-
-// TestMain installs ecore's fixed test keyset into core-go's process-global
-// crypto, so Encrypt/Decrypt work offline. The keys are constants and the call
-// is idempotent, which is what lets this package and web/ both initialise
-// without the second rotating what the first sealed with.
-func TestMain(m *testing.M) {
- ecoretest.InitCrypto()
- os.Exit(m.Run())
-}
-
-// sealCookie builds a valid unified-login cookie value carrying the given name.
-func sealCookie(t *testing.T, name string) string {
- t.Helper()
- payload, err := json.Marshal(map[string]string{"name": name})
- if err != nil {
- t.Fatalf("marshal claims: %v", err)
- }
- return string(crypto.Encrypt(payload))
-}
-
-func TestUsernameFromRequest_RoundTrip(t *testing.T) {
- r := httptest.NewRequest(http.MethodGet, "/", nil)
- r.AddCookie(&http.Cookie{Name: CookieName, Value: sealCookie(t, "bigbes")})
- if got := UsernameFromRequest(r); got != "bigbes" {
- t.Fatalf("username = %q, want %q", got, "bigbes")
- }
-}
-
-func TestUsernameFromRequest_StripsTilde(t *testing.T) {
- r := httptest.NewRequest(http.MethodGet, "/", nil)
- r.AddCookie(&http.Cookie{Name: CookieName, Value: sealCookie(t, "~bigbes")})
- if got := UsernameFromRequest(r); got != "bigbes" {
- t.Fatalf("username = %q, want %q", got, "bigbes")
- }
-}
-
-func TestUsernameFromRequest_GarbageCookie(t *testing.T) {
- r := httptest.NewRequest(http.MethodGet, "/", nil)
- r.AddCookie(&http.Cookie{Name: CookieName, Value: "not-a-valid-fernet-token"})
- if got := UsernameFromRequest(r); got != "" {
- t.Fatalf("username = %q, want empty", got)
- }
-}
-
-func TestUsernameFromRequest_MissingCookie(t *testing.T) {
- r := httptest.NewRequest(http.MethodGet, "/", nil)
- if got := UsernameFromRequest(r); got != "" {
- t.Fatalf("username = %q, want empty", got)
- }
-}
-
-func TestUsernameFromRequest_NonJSONPayload(t *testing.T) {
- r := httptest.NewRequest(http.MethodGet, "/", nil)
- // A well-formed Fernet token whose plaintext is not JSON.
- r.AddCookie(&http.Cookie{Name: CookieName, Value: string(crypto.Encrypt([]byte("plain text")))})
- if got := UsernameFromRequest(r); got != "" {
- t.Fatalf("username = %q, want empty", got)
- }
-}
-
-func TestMiddlewareAndForContext(t *testing.T) {
- var seen string
- h := Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- seen = ForContext(r.Context())
- }))
-
- r := httptest.NewRequest(http.MethodGet, "/", nil)
- r.AddCookie(&http.Cookie{Name: CookieName, Value: sealCookie(t, "bigbes")})
- h.ServeHTTP(httptest.NewRecorder(), r)
- if seen != "bigbes" {
- t.Fatalf("ForContext = %q, want %q", seen, "bigbes")
- }
-
- // Anonymous request: middleware still runs, ForContext yields "".
- seen = "sentinel"
- h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
- if seen != "" {
- t.Fatalf("anonymous ForContext = %q, want empty", seen)
- }
-}
-
-func TestForContext_NoValue(t *testing.T) {
- if got := ForContext(httptest.NewRequest(http.MethodGet, "/", nil).Context()); got != "" {
- t.Fatalf("ForContext on bare context = %q, want empty", got)
- }
-}
M cmd/comparesrht/main.go => cmd/comparesrht/main.go +6 -2
@@ 38,6 38,7 @@ import (
"go.bigb.es/auxilia/scribe"
"sourcecraft.dev/bigbes/sr-ht-core/config"
coreserver "sourcecraft.dev/bigbes/sr-ht-core/server"
+ "sourcecraft.dev/bigbes/sr-ht-ecore/login"
"sourcecraft.dev/bigbes/sr-ht-compare/authz"
"sourcecraft.dev/bigbes/sr-ht-compare/web"
@@ 122,7 123,7 @@ func main() {
// Middleware chain per the web package contract (outermost first). This is
// the hand-rolled substitute for WithDefaultMiddleware: no database, no
- // redis, and authz.Middleware never issues a 401 so anonymous browsing
+ // redis, and login.Optional never issues a 401 so anonymous browsing
// works. config.Middleware must be present because the GraphQL authorizer
// resolves git.sr.ht's API origin from config.ForContext at request time.
//
@@ 142,7 143,10 @@ func main() {
r.Use(middleware.Recoverer)
r.Use(middleware.Logger)
r.Use(config.Middleware(conf, service))
- r.Use(authz.Middleware())
+ // The instance's one cookie decode, with the default validator: a name
+ // this service narrowed further would be an account logged out of
+ // compare alone, and meta.sr.ht is the authority on which names exist.
+ r.Use(login.Optional())
app.Register(r)
})
M => +3 -2
@@ 125,7 125,7 @@ Reuse the existing seams — do **not** invent a second authz path.
(`authz.Authorizer.Repo`), which 404s a repo the viewer cannot see (private
existence never leaks). Comments for a repo are only ever returned to a viewer
who passed that check.
- **Write**: require an authenticated viewer — `authz.ForContext(ctx) != ""`.
- **Write**: require an authenticated viewer — `login.FromContext(ctx) != ""`.
For the MVP, **any authenticated viewer who can read the repo may comment**
(open code review). Edit/delete restricted to the comment's `author`; resolve
allowed to the thread author or comment author.
@@ 247,7 247,8 @@ Confirm how the sibling Go services on this instance manage schema and match the
`connection-string`).
- Opt-out today: `cmd/comparesrht/main.go` (middleware `Group`, `validateConfig`).
- Authz seams: `authz/authz.go` (`Authorizer`, `RepoInfo`),
`authz/identity.go` (`ForContext`), `web/handlers.go` (`s.resolve`).
`sr-ht-ecore/login` (`Optional`, `FromContext`), `web/handlers.go`
(`s.resolve`).
- Routes / SSR contract: `web/router.go` (`Register`), `web/server.go`,
`web/handlers.go` (`buildCompareJSON`, `#compare-data`).
- Diff library API: `@pierre/diffs` `FileDiffOptions.renderAnnotation`,
M web/handlers.go => web/handlers.go +3 -2
@@ 13,6 13,7 @@ import (
"github.com/go-chi/chi/v5"
"go.bigb.es/auxilia/scribe"
"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
+ "sourcecraft.dev/bigbes/sr-ht-ecore/login"
"sourcecraft.dev/bigbes/sr-ht-compare/authz"
"sourcecraft.dev/bigbes/sr-ht-compare/core"
@@ 132,7 133,7 @@ func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
// authz metadata. Any error is already mapped to the right HTTP status by the
// caller via fail.
func (s *Server) resolve(ctx context.Context, owner, repo string) (*gitx.Repo, *authz.RepoInfo, error) {
- viewer := authz.ForContext(ctx)
+ viewer := login.FromContext(ctx)
info, err := s.authorizer.Repo(ctx, viewer, owner, repo)
if err != nil {
return nil, nil, err
@@ 157,7 158,7 @@ type indexData struct {
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- username := authz.ForContext(ctx)
+ username := login.FromContext(ctx)
// The title is built from the chrome's own brand fields rather than from a
// second read of site-name, so the tab and the nav cannot name the instance
M web/server.go => web/server.go +13 -7
@@ 3,7 3,7 @@
// embeds a compact JSON payload plus the vendored esbuild bundle so the browser
// renders the diff with @pierre/diffs and @pierre/trees.
//
-// The package owns no state of its own: identity comes from the authz cookie
+// The package owns no state of its own: identity comes from ecore's login
// middleware, authorization from an authz.Authorizer (git.sr.ht GraphQL), and
// git data from gitx over bare repositories on disk. Every request that touches
// a repository authorizes first (a not-found or forbidden repo is a 404, never
@@ 38,9 38,13 @@
// chi middleware.Recoverer
// chi middleware.Logger (optional, but recommended)
// config.Middleware(conf, "compare.sr.ht") // required: authz + gitx read it
-// authz.Middleware() // required: never 401s; sets the viewer
+// login.Optional() // required: never 401s; sets the viewer
//
-// config.Middleware must run before authz.Middleware is irrelevant to authz
+// login.Optional and not login.Required: every page here is either public or a
+// 404, and git.sr.ht decides which — a viewer this service refused would be a
+// viewer git.sr.ht was never asked about.
+//
+// config.Middleware must run before login.Optional is irrelevant to login
// itself (it only reads the cookie), but the GraphQL authorizer invoked inside
// handlers needs config.ForContext(ctx) to resolve git.sr.ht's API origin, so
// config.Middleware is mandatory on every request that reaches a handler.
@@ 55,6 59,7 @@ import (
"go.bigb.es/auxilia/culpa"
"sourcecraft.dev/bigbes/sr-ht-ecore/assets"
"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
+ "sourcecraft.dev/bigbes/sr-ht-ecore/login"
"sourcecraft.dev/bigbes/sr-ht-ecore/pages"
"sourcecraft.dev/bigbes/sr-ht-compare/authz"
@@ 201,9 206,10 @@ type viewData struct {
// view builds the frame for one request: the shared chrome plus a title.
//
-// The username is whatever the authz cookie middleware resolved, which is "" for
-// a viewer whose cookie is missing, expired or unreadable — so the nav offers
-// login to exactly the viewers the handlers treat as anonymous.
+// The username is whatever login.Optional resolved, which is "" for a viewer
+// whose cookie is missing, expired, unreadable or carries a name that could not
+// be one — so the nav offers login to exactly the viewers the handlers treat as
+// anonymous.
func (s *Server) view(r *http.Request, title string) viewData {
- return viewData{Page: s.chromeSvc.Page(r, title, authz.ForContext(r.Context()))}
+ return viewData{Page: s.chromeSvc.Page(r, title, login.FromContext(r.Context()))}
}
M web/web_test.go => web/web_test.go +8 -6
@@ 22,6 22,7 @@ import (
"sourcecraft.dev/bigbes/sr-ht-ecore/assets"
"sourcecraft.dev/bigbes/sr-ht-ecore/csrf"
"sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest"
+ "sourcecraft.dev/bigbes/sr-ht-ecore/login"
"sourcecraft.dev/bigbes/sr-ht-ecore/pages"
"sourcecraft.dev/bigbes/sr-ht-compare/authz"
@@ 30,7 31,7 @@ import (
)
// TestMain seeds core-go's process-global crypto with ecore's fixed test
-// keyset, which is what lets login below seal a unified-login cookie the authz
+// keyset, which is what lets logIn below seal a unified-login cookie the login
// middleware can open again.
func TestMain(m *testing.M) {
ecoretest.InitCrypto()
@@ 153,15 154,16 @@ func testServer(t *testing.T, root string, az authz.Authorizer) http.Handler {
r := chi.NewRouter()
r.Use(config.Middleware(conf, "compare.sr.ht"))
- r.Use(authz.Middleware())
+ r.Use(login.Optional())
srv.Register(r)
return r
}
-// login seals a unified-login cookie for the given user onto a request.
-func login(req *http.Request, user string) {
+// logIn seals a unified-login cookie for the given user onto a request. It is
+// not called login because that is the package that opens the cookie again.
+func logIn(req *http.Request, user string) {
payload, _ := json.Marshal(map[string]string{"name": user})
- req.AddCookie(&http.Cookie{Name: authz.CookieName, Value: string(crypto.Encrypt(payload))})
+ req.AddCookie(&http.Cookie{Name: login.CookieName, Value: string(crypto.Encrypt(payload))})
}
func demoAuthorizer() *stubAuthorizer {
@@ 180,7 182,7 @@ func get(t *testing.T, h http.Handler, target string, user string) *httptest.Res
t.Helper()
req := httptest.NewRequest(http.MethodGet, target, nil)
if user != "" {
- login(req, user)
+ logIn(req, user)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)