Files
broker/pkg/admin/admin.go
T
zeekayandHanzo Dev d034c38050 fix(auth): reject non-canonical base64 in bearer tokens
Go's base64 decoders are lenient in two independent ways, and both JWT
verifiers used them raw, so one credential validated under several spellings.

The unused low bits of the final quantum are ignored: an HMAC-SHA256
signature is 32 bytes in 43 base64url characters, leaving 2 slack bits and
so 4 accepted strings per admin token; an RSA-2048 signature is 256 bytes in
342 characters, leaving 4 slack bits and so 16 per IAM token. Separately,
\r and \n are skipped wherever they appear, which Encoding.Strict() does not
close -- so Strict alone would not have been a fix.

Every string-keyed structure over a credential silently splits under that: a
denylist misses the spellings it was not handed, an audit log reads one actor
as several, a rate limiter multiplies the budget by the class size.

pkg/token holds the one canonical encoding. Decode round-trips rather than
enumerating failure modes -- a segment is canonical exactly when it is the
encoding of the bytes it decodes to -- and rejects rather than normalizes.
token.ID is the hash to key revocation, audit and rate limiting on, so the
identity is never the submitted string.

Both verifiers now decode every segment through it. JWKS n/e stays lenient on
purpose: public key material from the trusted IAM endpoint, where every
spelling is the same key.

Also in pkg/admin: sign returns raw MAC bytes instead of text, dropping an
encode-then-decode round trip in the verify path; the header is now decoded
and required to declare HS256, closing alg confusion under future refactor;
and a comment referencing a long-deleted hashPassword is gone.

Tests: exactly one spelling authenticates on both paths; revocation keyed on
token.ID holds across all four; CR/LF, padding, standard alphabet and empty
segments rejected. TestValidateToken_TamperedSignature substituted a fixed
'X' that was a no-op whenever the token already ended in one -- a 1-in-64
flake that this bug made a 1-in-16 one -- now derived from the token.

LLM.md: admin auth section corrected (bcrypt cost 12, not SHA-256+salt; no
ADMIN_* env vars exist; middleware strips those headers rather than setting
them; package is not mounted).

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 18:13:16 -07:00

278 lines
8.1 KiB
Go

// Package admin provides JWT-based authentication for admin dashboard endpoints.
// Passwords are always stored as bcrypt hashes — never plaintext.
package admin
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/luxfi/broker/pkg/token"
)
// alg is the only signature algorithm this package issues or accepts. The
// verifier never dispatches on the token's own header — it requires this.
const alg = "HS256"
// contextKey is an unexported type for context keys in this package.
type contextKey string
const (
// ContextKeyAdminUser is the context key for the admin username.
ContextKeyAdminUser contextKey = "admin_user"
// ContextKeyAdminRole is the context key for the admin role.
ContextKeyAdminRole contextKey = "admin_role"
)
// UserFromContext returns the admin username from the request context.
func UserFromContext(ctx context.Context) string {
v, _ := ctx.Value(ContextKeyAdminUser).(string)
return v
}
// RoleFromContext returns the admin role from the request context.
func RoleFromContext(ctx context.Context) string {
v, _ := ctx.Value(ContextKeyAdminRole).(string)
return v
}
// Admin represents an admin user with hashed credentials.
type Admin struct {
Username string `json:"username"`
PasswordHash string `json:"-"` // bcrypt hash
Role string `json:"role"` // super_admin, admin, reviewer
CreatedAt time.Time `json:"created_at"`
}
// Store manages admin users. In production, back this with a database.
type Store struct {
mu sync.RWMutex
admins map[string]*Admin // username -> Admin
secret []byte // JWT signing secret
}
// NewStore creates an admin store with the given JWT signing secret.
func NewStore(jwtSecret string) *Store {
return &Store{
admins: make(map[string]*Admin),
secret: []byte(jwtSecret),
}
}
// AddAdmin registers an admin user. Password is hashed with bcrypt before storage.
func (s *Store) AddAdmin(username, password, role string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
s.admins[username] = &Admin{
Username: username,
PasswordHash: string(hash),
Role: role,
CreatedAt: time.Now(),
}
return nil
}
// Authenticate validates admin credentials and returns a JWT token.
func (s *Store) Authenticate(username, password string) (string, error) {
s.mu.RLock()
admin, ok := s.admins[username]
s.mu.RUnlock()
if !ok {
return "", fmt.Errorf("invalid credentials")
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
return "", fmt.Errorf("invalid credentials")
}
return s.generateJWT(admin)
}
// ValidateToken validates a JWT and returns the claims.
//
// Every segment is decoded canonically: a token is its bytes, and exactly one
// string encodes those bytes. Accepting a second spelling would hand out one
// credential under several identities, which is what breaks a denylist.
func (s *Store) ValidateToken(tokenStr string) (*Claims, error) {
parts := strings.Split(tokenStr, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid token format")
}
headerJSON, err := token.Decode(parts[0])
if err != nil {
return nil, fmt.Errorf("invalid token header")
}
var header struct {
Alg string `json:"alg"`
}
if err := json.Unmarshal(headerJSON, &header); err != nil || header.Alg != alg {
return nil, fmt.Errorf("invalid token header")
}
// Constant-time compare of raw MAC bytes — never of their text.
sig, err := token.Decode(parts[2])
if err != nil {
return nil, fmt.Errorf("invalid token signature")
}
if !hmac.Equal(sig, sign([]byte(parts[0]+"."+parts[1]), s.secret)) {
return nil, fmt.Errorf("invalid token signature")
}
claimsJSON, err := token.Decode(parts[1])
if err != nil {
return nil, fmt.Errorf("invalid token payload")
}
var claims Claims
if err := json.Unmarshal(claimsJSON, &claims); err != nil {
return nil, fmt.Errorf("invalid token claims")
}
// Check expiration
if time.Now().Unix() > claims.Exp {
return nil, fmt.Errorf("token expired")
}
return &claims, nil
}
// Claims are the JWT payload fields.
type Claims struct {
Sub string `json:"sub"` // username
Role string `json:"role"` // admin role
Iat int64 `json:"iat"` // issued at
Exp int64 `json:"exp"` // expires at
}
// Middleware returns HTTP middleware that validates admin JWT tokens.
// Tokens are passed as: Authorization: Bearer <token>
// Claims are stored in the request context, not HTTP headers.
func Middleware(store *Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Strip any incoming claim headers to prevent injection
r.Header.Del("X-Admin-User")
r.Header.Del("X-Admin-Role")
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
writeAdminError(w, http.StatusUnauthorized, "admin token required")
return
}
token := strings.TrimPrefix(auth, "Bearer ")
claims, err := store.ValidateToken(token)
if err != nil {
writeAdminError(w, http.StatusUnauthorized, err.Error())
return
}
// Pass claims through request context — never via headers
ctx := context.WithValue(r.Context(), ContextKeyAdminUser, claims.Sub)
ctx = context.WithValue(ctx, ContextKeyAdminRole, claims.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func (s *Store) generateJWT(admin *Admin) (string, error) {
header := token.Encode([]byte(`{"alg":"` + alg + `","typ":"JWT"}`))
claims := Claims{
Sub: admin.Username,
Role: admin.Role,
Iat: time.Now().Unix(),
Exp: time.Now().Add(24 * time.Hour).Unix(),
}
claimsJSON, _ := json.Marshal(claims)
payload := token.Encode(claimsJSON)
signingInput := header + "." + payload
return signingInput + "." + token.Encode(sign([]byte(signingInput), s.secret)), nil
}
// sign returns the raw MAC. Bytes are the value; encoding happens only at the
// wire boundary, so nothing downstream can compare or key on the text.
func sign(data, secret []byte) []byte {
mac := hmac.New(sha256.New, secret)
mac.Write(data)
return mac.Sum(nil)
}
// LoginHandler returns an http.HandlerFunc that accepts POST {username, password}
// and returns {token: "jwt..."} on success.
func LoginHandler(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeAdminError(w, http.StatusMethodNotAllowed, "POST required")
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeAdminError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Username == "" || req.Password == "" {
writeAdminError(w, http.StatusBadRequest, "username and password required")
return
}
token, err := store.Authenticate(req.Username, req.Password)
if err != nil {
writeAdminError(w, http.StatusUnauthorized, "invalid credentials")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"token": token})
}
}
// VerifyHandler returns an http.HandlerFunc that validates the Bearer token
// from the Authorization header and returns the decoded claims on success.
func VerifyHandler(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
writeAdminError(w, http.StatusUnauthorized, "token required")
return
}
token := strings.TrimPrefix(auth, "Bearer ")
claims, err := store.ValidateToken(token)
if err != nil {
writeAdminError(w, http.StatusUnauthorized, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(claims)
}
}
func writeAdminError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}