feat: initial shared auth module
auth / test (push) Has been skipped
auth / vulnerabilities (push) Has been skipped

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:
2026-06-15 11:43:11 +02:00
commit 81ac3e6ea5
9 changed files with 273 additions and 0 deletions
+35
View File
@@ -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 ./...
+4
View File
@@ -0,0 +1,4 @@
.idea
.claude
/release
coverage.txt
+12
View File
@@ -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.
+11
View File
@@ -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
)
+10
View File
@@ -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=
+78
View File
@@ -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
}
+75
View File
@@ -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
View File
@@ -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
}
+22
View File
@@ -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")))
}