package jwtauth import ( "context" "crypto/subtle" "encoding/json" "errors" "log/slog" "net/http" "strings" "time" "commilitia.net/cdrop/internal/config" "commilitia.net/cdrop/internal/db" ) // Store is the subset of *db.Queries the auth middleware uses: refreshing a managed // device's last_seen + tier on each request. Declared as an interface so tests can // swap in a fake. type Store interface { TouchDevice(ctx context.Context, arg db.TouchDeviceParams) error } // Authenticator turns each request's identity into Claims. After the Auth Broker // migration (path A) cdrop no longer verifies tokens itself: in prod the broker // authenticates at the edge and injects X-Auth-* headers this process trusts; in dev // the claims are synthesised from the dev token. type Authenticator struct { cfg *config.Config store Store } func New(cfg *config.Config, store Store) *Authenticator { return &Authenticator{cfg: cfg, store: store} } func (a *Authenticator) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims, err := a.authenticate(r) if err != nil { slog.Warn("auth failed", "err", err, "path", r.URL.Path) unauthorized(w, "unauthorized") return } deviceName := SanitizeDeviceName(r.Header.Get("X-Device-Name")) deviceType := normalizeDeviceType(r.Header.Get("X-Device-Type")) // Keep the managed device's last_seen + tier fresh. Identity is the // broker-issued device_id (X-Auth-Meta); the row is created at scan-login // collect, so this only ever updates — an unmanaged caller (no device_id, // e.g. a global SSO browser) is skipped, and a missing row no-ops. if claims.DeviceID != "" { if err := a.store.TouchDevice(r.Context(), db.TouchDeviceParams{ LastSeen: time.Now().Unix(), Tier: claims.Tier(), DeviceID: claims.DeviceID, UserID: claims.UserID, }); err != nil { // non-fatal: log and continue so a transient DB error doesn't 401 users slog.Error("device touch failed", "err", err, "user", claims.UserID, "device", claims.DeviceID) } } ctx := ContextWithClaims(r.Context(), claims) ctx = context.WithValue(ctx, deviceCtxKey, deviceName) ctx = context.WithValue(ctx, deviceTypeCtxKey, deviceType) next.ServeHTTP(w, r.WithContext(ctx)) }) } // authenticate resolves the request identity. prod trusts the broker's edge-injected // X-Auth-* headers; dev synthesises claims from the dev token. func (a *Authenticator) authenticate(r *http.Request) (*Claims, error) { if a.cfg.AuthMode == "dev" { return a.devClaims(r) } // prod: the request reached cdrop only by passing broker /verify at the edge, // which injected these headers. Caddy strips any client-supplied X-Auth-* at the // trust boundary, so their presence is the broker's say-so. No subject → the // request did not authenticate. sub := r.Header.Get("X-Auth-Subject") if sub == "" { return nil, errors.New("missing X-Auth-Subject (request did not pass broker /verify)") } return &Claims{ UserID: sub, Name: r.Header.Get("X-Auth-Name"), Avatar: r.Header.Get("X-Auth-Avatar"), Groups: splitRoles(r.Header.Get("X-Auth-Roles")), Scope: r.Header.Get("X-Auth-Scope"), DeviceID: r.Header.Get("X-Auth-Meta"), }, nil } // devClaims authenticates the local dev token and synthesises claims. X-Dev-User sets // the subject (default "dev-user"); X-Dev-Scope simulates a tier ("guest" → restricted, // else full); X-Dev-Device optionally sets a device_id to exercise device flows. func (a *Authenticator) devClaims(r *http.Request) (*Claims, error) { token, ok := bearerToken(r) if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(a.cfg.DevToken)) != 1 { return nil, errors.New("invalid dev token") } userID := r.Header.Get("X-Dev-User") if userID == "" { userID = "dev-user" } scope := r.Header.Get("X-Dev-Scope") if scope == "" { scope = "full" } return &Claims{ UserID: userID, Name: userID, Groups: []string{"dev"}, Scope: scope, DeviceID: r.Header.Get("X-Dev-Device"), }, nil } // splitRoles parses a comma-separated X-Auth-Roles header into a role slice, // trimming whitespace and dropping empties. func splitRoles(raw string) []string { if raw == "" { return nil } parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if s := strings.TrimSpace(p); s != "" { out = append(out, s) } } return out } func bearerToken(r *http.Request) (string, bool) { h := r.Header.Get("Authorization") const prefix = "Bearer " if !strings.HasPrefix(h, prefix) { return "", false } tok := strings.TrimPrefix(h, prefix) if tok == "" { return "", false } return tok, true } func unauthorized(w http.ResponseWriter, reason string) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("WWW-Authenticate", `Bearer realm="cdrop"`) w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]string{ "error": "unauthorized", "reason": reason, }) } // SanitizeDeviceName enforces the global ASCII-only device-name policy. Device names // ride in the X-Device-Name HTTP header, which can't carry non-ASCII reliably (and // browser fetch rejects such header values outright), so the name is restricted to // printable ASCII everywhere. Here we keep only printable ASCII (0x20–0x7E), trim, and // cap the length as a server-side backstop; clients also validate up front. Empty after // sanitising → the caller falls back to a default. func SanitizeDeviceName(raw string) string { var b strings.Builder for _, r := range raw { if r >= 0x20 && r <= 0x7E { b.WriteRune(r) } } name := strings.TrimSpace(b.String()) if len(name) > 64 { name = strings.TrimSpace(name[:64]) } return name } // normalizeDeviceType whitelists the client-declared X-Device-Type so a device row // only ever carries a known kind; anything unrecognised (incl. empty) falls back to // "browser". Native clients send macos/windows/linux/ios. func normalizeDeviceType(raw string) string { switch t := strings.ToLower(strings.TrimSpace(raw)); t { case "macos", "windows", "linux", "ios", "browser": return t default: return "browser" } }