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.
This commit is contained in:
@@ -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 ./...
|
||||
@@ -0,0 +1,4 @@
|
||||
.idea
|
||||
.claude
|
||||
/release
|
||||
coverage.txt
|
||||
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
@@ -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")))
|
||||
}
|
||||
Reference in New Issue
Block a user