middleware.go 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. // Package authd — middleware.go: HTTP middleware for the JWT gate
  2. // (M13a W2). Services like ingestd, routerd, deliverd-* use this
  3. // to require a valid Bearer access token on protected routes, and
  4. // to get the (user_id, tenant_id, role) claims into the request
  5. // context for downstream logging and authorization.
  6. //
  7. // Threading: safe for concurrent use. The Authd it wraps is
  8. // shared across requests.
  9. package authd
  10. import (
  11. "context"
  12. "errors"
  13. "net/http"
  14. "strings"
  15. )
  16. // ctxKey is unexported so external packages can't accidentally
  17. // collide on the same context key.
  18. type ctxKey int
  19. const (
  20. ctxKeyClaims ctxKey = iota + 1
  21. )
  22. // ClaimsFromContext returns the JWT claims that RequireAuth stored
  23. // in ctx, or nil if none. Used by handlers downstream of the
  24. // middleware to read user_id / tenant_id / role.
  25. func ClaimsFromContext(ctx context.Context) *AccessClaims {
  26. v, ok := ctx.Value(ctxKeyClaims).(*AccessClaims)
  27. if !ok {
  28. return nil
  29. }
  30. return v
  31. }
  32. // withClaims stores the claims in ctx. Internal — handlers should
  33. // use ClaimsFromContext.
  34. func withClaims(ctx context.Context, c *AccessClaims) context.Context {
  35. return context.WithValue(ctx, ctxKeyClaims, c)
  36. }
  37. // ---------------------------------------------------------------------------
  38. // Middleware
  39. // ---------------------------------------------------------------------------
  40. // RequireAuth is the middleware. Wrap any handler that needs
  41. // authentication:
  42. //
  43. // mux.Handle("POST /v1/admin/foo", ad.RequireAuth(myHandler))
  44. //
  45. // On success, the handler is called with the claims in context
  46. // (ClaimsFromContext). On failure, it writes a 401 with a JSON body
  47. // and does NOT call the wrapped handler.
  48. func (a *Authd) RequireAuth(next http.Handler) http.Handler {
  49. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  50. tok, err := bearerFromRequest(r)
  51. if err != nil {
  52. writeUnauthorized(w, "missing_bearer", err.Error())
  53. return
  54. }
  55. claims, err := a.VerifyAccessToken(tok)
  56. if err != nil {
  57. writeUnauthorized(w, "invalid_token", "access token invalid or expired")
  58. return
  59. }
  60. next.ServeHTTP(w, r.WithContext(withClaims(r.Context(), claims)))
  61. })
  62. }
  63. // RequireRole is like RequireAuth but additionally checks that the
  64. // JWT carries one of the allowed roles. Returns 403 if the role
  65. // doesn't match.
  66. //
  67. // mux.Handle("POST /v1/users/invite",
  68. // ad.RequireRole("super_admin", "tenant_admin")(inviteHandler))
  69. func (a *Authd) RequireRole(allowed ...string) func(http.Handler) http.Handler {
  70. allowedSet := make(map[string]struct{}, len(allowed))
  71. for _, r := range allowed {
  72. allowedSet[r] = struct{}{}
  73. }
  74. return func(next http.Handler) http.Handler {
  75. return a.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  76. claims := ClaimsFromContext(r.Context())
  77. if claims == nil {
  78. // Should not happen — RequireAuth would have 401'd.
  79. writeUnauthorized(w, "internal", "claims missing")
  80. return
  81. }
  82. if _, ok := allowedSet[claims.Role]; !ok {
  83. w.Header().Set("Content-Type", "application/json")
  84. w.WriteHeader(http.StatusForbidden)
  85. _, _ = w.Write([]byte(`{"error":"forbidden","message":"role not allowed"}`))
  86. return
  87. }
  88. next.ServeHTTP(w, r)
  89. }))
  90. }
  91. }
  92. // bearerFromRequest extracts the raw token from the Authorization
  93. // header, accepting the case-insensitive "Bearer" scheme.
  94. func bearerFromRequest(r *http.Request) (string, error) {
  95. h := r.Header.Get("Authorization")
  96. if h == "" {
  97. return "", errors.New("Authorization header missing")
  98. }
  99. // "Bearer <token>" — split on the first whitespace.
  100. const scheme = "bearer"
  101. if len(h) < len(scheme)+1 {
  102. return "", errors.New("Authorization header too short")
  103. }
  104. // case-insensitive scheme match
  105. prefix := strings.ToLower(h[:len(scheme)])
  106. if prefix != scheme {
  107. return "", errors.New("Authorization must use Bearer scheme")
  108. }
  109. rest := strings.TrimSpace(h[len(scheme):])
  110. if rest == "" {
  111. return "", errors.New("bearer token empty")
  112. }
  113. // Some clients send "Bearer\t<token>" — trim space above handles.
  114. // Also reject "Bearer <token>" (double space) and "Bearer, ...".
  115. return rest, nil
  116. }
  117. // writeUnauthorized writes a 401 with a JSON error body. Duplicated
  118. // in cmd/authd/main.go for now; v1.1 should move it to a shared
  119. // helper in internal/httpserver.
  120. func writeUnauthorized(w http.ResponseWriter, code, msg string) {
  121. w.Header().Set("Content-Type", "application/json")
  122. w.Header().Set("WWW-Authenticate", `Bearer realm="broad-announce"`)
  123. w.WriteHeader(http.StatusUnauthorized)
  124. _, _ = w.Write([]byte(`{"error":"` + code + `","message":"` + msg + `"}`))
  125. }