Files
Commilitia-Drop/internal/jwtauth/middleware.go
T

192 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="Commilitia Drop"`)
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 (0x200x7E), 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"
}
}