| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135 |
- // Package authd — middleware.go: HTTP middleware for the JWT gate
- // (M13a W2). Services like ingestd, routerd, deliverd-* use this
- // to require a valid Bearer access token on protected routes, and
- // to get the (user_id, tenant_id, role) claims into the request
- // context for downstream logging and authorization.
- //
- // Threading: safe for concurrent use. The Authd it wraps is
- // shared across requests.
- package authd
- import (
- "context"
- "errors"
- "net/http"
- "strings"
- )
- // ctxKey is unexported so external packages can't accidentally
- // collide on the same context key.
- type ctxKey int
- const (
- ctxKeyClaims ctxKey = iota + 1
- )
- // ClaimsFromContext returns the JWT claims that RequireAuth stored
- // in ctx, or nil if none. Used by handlers downstream of the
- // middleware to read user_id / tenant_id / role.
- func ClaimsFromContext(ctx context.Context) *AccessClaims {
- v, ok := ctx.Value(ctxKeyClaims).(*AccessClaims)
- if !ok {
- return nil
- }
- return v
- }
- // withClaims stores the claims in ctx. Internal — handlers should
- // use ClaimsFromContext.
- func withClaims(ctx context.Context, c *AccessClaims) context.Context {
- return context.WithValue(ctx, ctxKeyClaims, c)
- }
- // ---------------------------------------------------------------------------
- // Middleware
- // ---------------------------------------------------------------------------
- // RequireAuth is the middleware. Wrap any handler that needs
- // authentication:
- //
- // mux.Handle("POST /v1/admin/foo", ad.RequireAuth(myHandler))
- //
- // On success, the handler is called with the claims in context
- // (ClaimsFromContext). On failure, it writes a 401 with a JSON body
- // and does NOT call the wrapped handler.
- func (a *Authd) RequireAuth(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- tok, err := bearerFromRequest(r)
- if err != nil {
- writeUnauthorized(w, "missing_bearer", err.Error())
- return
- }
- claims, err := a.VerifyAccessToken(tok)
- if err != nil {
- writeUnauthorized(w, "invalid_token", "access token invalid or expired")
- return
- }
- next.ServeHTTP(w, r.WithContext(withClaims(r.Context(), claims)))
- })
- }
- // RequireRole is like RequireAuth but additionally checks that the
- // JWT carries one of the allowed roles. Returns 403 if the role
- // doesn't match.
- //
- // mux.Handle("POST /v1/users/invite",
- // ad.RequireRole("super_admin", "tenant_admin")(inviteHandler))
- func (a *Authd) RequireRole(allowed ...string) func(http.Handler) http.Handler {
- allowedSet := make(map[string]struct{}, len(allowed))
- for _, r := range allowed {
- allowedSet[r] = struct{}{}
- }
- return func(next http.Handler) http.Handler {
- return a.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- claims := ClaimsFromContext(r.Context())
- if claims == nil {
- // Should not happen — RequireAuth would have 401'd.
- writeUnauthorized(w, "internal", "claims missing")
- return
- }
- if _, ok := allowedSet[claims.Role]; !ok {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusForbidden)
- _, _ = w.Write([]byte(`{"error":"forbidden","message":"role not allowed"}`))
- return
- }
- next.ServeHTTP(w, r)
- }))
- }
- }
- // bearerFromRequest extracts the raw token from the Authorization
- // header, accepting the case-insensitive "Bearer" scheme.
- func bearerFromRequest(r *http.Request) (string, error) {
- h := r.Header.Get("Authorization")
- if h == "" {
- return "", errors.New("Authorization header missing")
- }
- // "Bearer <token>" — split on the first whitespace.
- const scheme = "bearer"
- if len(h) < len(scheme)+1 {
- return "", errors.New("Authorization header too short")
- }
- // case-insensitive scheme match
- prefix := strings.ToLower(h[:len(scheme)])
- if prefix != scheme {
- return "", errors.New("Authorization must use Bearer scheme")
- }
- rest := strings.TrimSpace(h[len(scheme):])
- if rest == "" {
- return "", errors.New("bearer token empty")
- }
- // Some clients send "Bearer\t<token>" — trim space above handles.
- // Also reject "Bearer <token>" (double space) and "Bearer, ...".
- return rest, nil
- }
- // writeUnauthorized writes a 401 with a JSON error body. Duplicated
- // in cmd/authd/main.go for now; v1.1 should move it to a shared
- // helper in internal/httpserver.
- func writeUnauthorized(w http.ResponseWriter, code, msg string) {
- w.Header().Set("Content-Type", "application/json")
- w.Header().Set("WWW-Authenticate", `Bearer realm="broad-announce"`)
- w.WriteHeader(http.StatusUnauthorized)
- _, _ = w.Write([]byte(`{"error":"` + code + `","message":"` + msg + `"}`))
- }
|