From 81ac3e6ea5204c93347795f63dc33008e1c00e2c Mon Sep 17 00:00:00 2001 From: Joakim Olsson Date: Mon, 15 Jun 2026 11:43:11 +0200 Subject: [PATCH] feat: initial shared auth module Signed user-header middleware (UserMiddleware/FromContext/User, ADR-0005) plus the deployed-secrets startup guard (MissingDeployedSecrets, ADR-0005/0006). Replaces the byte-identical auth package + secrets_guard.go copied into every backend service. --- .gitea/workflows/ci.yaml | 35 ++++++++++++++++++ .gitignore | 4 +++ README.md | 12 +++++++ go.mod | 11 ++++++ go.sum | 10 ++++++ middleware.go | 78 ++++++++++++++++++++++++++++++++++++++++ middleware_test.go | 75 ++++++++++++++++++++++++++++++++++++++ secrets.go | 26 ++++++++++++++ secrets_test.go | 22 ++++++++++++ 9 files changed, 273 insertions(+) create mode 100644 .gitea/workflows/ci.yaml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 middleware.go create mode 100644 middleware_test.go create mode 100644 secrets.go create mode 100644 secrets_test.go diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..332ab82 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,35 @@ +name: auth + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + if: gitea.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: 'stable' + - name: Format check + run: | + go install mvdan.cc/gofumpt@latest + test -z "$(gofumpt -l .)" + - name: Run tests + run: go test -race ./... + vulnerabilities: + if: gitea.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: 'stable' + - name: Check vulnerabilities + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31bb341 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +.claude +/release +coverage.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..596f930 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# auth + +Shared authentication primitives for Shiny backend services. + +- `UserMiddleware(signingKey)` — verifies the HMAC-signed `user` header the + gateway propagates (ADR-0005) and injects the `*User` into the request context. +- `FromContext(ctx)` / `User.HasRole(...)` — read the authenticated user. +- `MissingDeployedSecrets(env, secrets)` — startup guard that fails closed when + required secrets are empty in `staging`/`production` (ADR-0005/0006). + +Replaces the byte-identical `auth` package and `secrets_guard.go` previously +copied into every service. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e108c25 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module gitea.unbound.se/shiny/auth + +go 1.25 + +require github.com/stretchr/testify v1.10.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..713a0b4 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..b386aeb --- /dev/null +++ b/middleware.go @@ -0,0 +1,78 @@ +package auth + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" +) + +type User struct { + Email string `json:"email"` + Roles []string `json:"roles"` +} + +func (u *User) HasRole(role ...string) bool { + for _, r := range role { + for _, o := range u.Roles { + if r == o { + return true + } + } + } + return false +} + +type ContextKey string + +const ( + UserKey = ContextKey("user") +) + +func UserMiddleware(signingKey []byte) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := &User{} + header := r.Header.Get("user") + if len(header) == 0 { + fmt.Println("User not found in request headers, check api-gateway") + next.ServeHTTP(w, r) + return + } + if len(signingKey) > 0 { + signature := r.Header.Get("user-signature") + if signature == "" { + http.Error(w, "missing user-signature header", http.StatusUnauthorized) + return + } + mac := hmac.New(sha256.New, signingKey) + mac.Write([]byte(header)) + expected := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(signature), []byte(expected)) { + http.Error(w, "invalid user-signature", http.StatusUnauthorized) + return + } + } + if err := json.Unmarshal([]byte(header), &user); err != nil { + fmt.Printf("User in header (%s) not parseable, check api-gateway", header) + next.ServeHTTP(w, r) + } else { + ctx := context.WithValue(r.Context(), UserKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + } + }) + } +} + +func FromContext(ctx context.Context) *User { + if user := ctx.Value(UserKey); user != nil { + if u, ok := user.(*User); ok { + return u + } + fmt.Println("User in context is not the correct type") + } + return nil +} diff --git a/middleware_test.go b/middleware_test.go new file mode 100644 index 0000000..e3c831e --- /dev/null +++ b/middleware_test.go @@ -0,0 +1,75 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func sign(key, header string) string { + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte(header)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func TestUserMiddleware(t *testing.T) { + key := "secret" + header := `{"email":"jim@example.org","roles":["admin"]}` + capture := func(next *bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *next = true + if u := FromContext(r.Context()); u != nil { + assert.Equal(t, "jim@example.org", u.Email) + assert.True(t, u.HasRole("admin")) + } + }) + } + + t.Run("valid signature passes and injects user", func(t *testing.T) { + called := false + req := httptest.NewRequest(http.MethodPost, "/query", nil) + req.Header.Set("user", header) + req.Header.Set("user-signature", sign(key, header)) + rw := httptest.NewRecorder() + UserMiddleware([]byte(key))(capture(&called)).ServeHTTP(rw, req) + assert.True(t, called) + assert.Equal(t, http.StatusOK, rw.Code) + }) + + t.Run("invalid signature is rejected", func(t *testing.T) { + called := false + req := httptest.NewRequest(http.MethodPost, "/query", nil) + req.Header.Set("user", header) + req.Header.Set("user-signature", "deadbeef") + rw := httptest.NewRecorder() + UserMiddleware([]byte(key))(capture(&called)).ServeHTTP(rw, req) + assert.False(t, called) + assert.Equal(t, http.StatusUnauthorized, rw.Code) + }) + + t.Run("missing signature when key set is rejected", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/query", nil) + req.Header.Set("user", header) + rw := httptest.NewRecorder() + UserMiddleware([]byte(key))(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(rw, req) + assert.Equal(t, http.StatusUnauthorized, rw.Code) + }) + + t.Run("empty key skips verification (dev only)", func(t *testing.T) { + called := false + req := httptest.NewRequest(http.MethodPost, "/query", nil) + req.Header.Set("user", header) + rw := httptest.NewRecorder() + UserMiddleware(nil)(capture(&called)).ServeHTTP(rw, req) + assert.True(t, called) + }) +} + +func TestFromContextNil(t *testing.T) { + assert.Nil(t, FromContext(httptest.NewRequest(http.MethodGet, "/", nil).Context())) +} diff --git a/secrets.go b/secrets.go new file mode 100644 index 0000000..3cb789a --- /dev/null +++ b/secrets.go @@ -0,0 +1,26 @@ +package auth + +import "sort" + +// MissingDeployedSecrets returns the names of secrets that must be non-empty in +// deployed environments (staging/production) but are currently unset. It returns +// nil for non-deployed environments (development, acctest) and when every +// required secret is present, so callers can treat a non-empty result as fatal. +// +// This closes the fail-open gap where an empty USER_SIGNING_KEY turns the +// user-header signature check into a no-op (forgeable identity) and an empty +// INTERNAL_API_KEY leaves the authz cache-hydration endpoint unauthenticated. +// See ADR-0005 and ADR-0006. +func MissingDeployedSecrets(environment string, secrets map[string]string) []string { + if environment != "staging" && environment != "production" { + return nil + } + var missing []string + for name, value := range secrets { + if value == "" { + missing = append(missing, name) + } + } + sort.Strings(missing) + return missing +} diff --git a/secrets_test.go b/secrets_test.go new file mode 100644 index 0000000..809cfc4 --- /dev/null +++ b/secrets_test.go @@ -0,0 +1,22 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMissingDeployedSecrets(t *testing.T) { + required := func(signing, internal string) map[string]string { + return map[string]string{"USER_SIGNING_KEY": signing, "INTERNAL_API_KEY": internal} + } + for _, env := range []string{"development", "", "acctest", "test"} { + assert.Nil(t, MissingDeployedSecrets(env, required("", "")), "env %q must not enforce", env) + } + assert.Nil(t, MissingDeployedSecrets("staging", required("k", "k"))) + assert.Nil(t, MissingDeployedSecrets("production", required("k", "k"))) + assert.Equal(t, []string{"INTERNAL_API_KEY", "USER_SIGNING_KEY"}, + MissingDeployedSecrets("staging", required("", ""))) + assert.Equal(t, []string{"USER_SIGNING_KEY"}, + MissingDeployedSecrets("production", required("", "k"))) +}