1package admin
2
3import (
4 "context"
5 "encoding/json"
6 "net"
7 "net/http"
8 "strings"
9
10 "github.com/bnema/zerowrap"
11
12 "github.com/bnema/gordon/internal/adapters/dto"
13 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
14 "github.com/bnema/gordon/internal/boundaries/in"
15 "github.com/bnema/gordon/internal/boundaries/out"
16 "github.com/bnema/gordon/internal/domain"
17)
18
19// AuthMiddleware creates middleware that validates admin API authentication.
20// Both globalLimiter and ipLimiter can be nil to disable rate limiting.
21// The trustedNets parameter is used for proper IP extraction behind reverse proxies.
22func AuthMiddleware(
23 authSvc in.AuthService,
24 globalLimiter out.RateLimiter,
25 ipLimiter out.RateLimiter,
26 trustedNets []*net.IPNet,
27 log zerowrap.Logger,
28) func(http.Handler) http.Handler {
29 return func(next http.Handler) http.Handler {
30 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31 ctx := r.Context()
32
33 // Check global rate limit
34 if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") {
35 w.Header().Set("Content-Type", "application/json")
36 w.Header().Set("Retry-After", "1")
37 w.WriteHeader(http.StatusTooManyRequests)
38 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
39 return
40 }
41
42 // Check per-IP rate limit
43 if ipLimiter != nil {
44 ip := middleware.GetClientIP(r, trustedNets)
45 if !ipLimiter.Allow(ctx, "ip:"+ip) {
46 w.Header().Set("Content-Type", "application/json")
47 w.Header().Set("Retry-After", "1")
48 w.WriteHeader(http.StatusTooManyRequests)
49 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
50 return
51 }
52 }
53
54 // Authentication is always required for admin APIs.
55 // Fail closed if auth is disabled or misconfigured.
56 if !authSvc.IsEnabled() {
57 log.Error().
58 Str("path", r.URL.Path).
59 Str("method", r.Method).
60 Str("remote_addr", r.RemoteAddr).
61 Msg("authentication disabled/misconfigured - denying admin API request")
62 sendUnauthorized(w, "authentication is required")
63 return
64 }
65
66 // Extract token from Authorization header.
67 // SECURITY: Require the standard "Bearer" prefix per RFC 6750
68 // to prevent accidental token exposure in non-standard formats.
69 auth := r.Header.Get("Authorization")
70 if auth == "" {
71 sendUnauthorized(w, "missing authorization header")
72 return
73 }
74
75 if !strings.HasPrefix(auth, "Bearer ") {
76 sendUnauthorized(w, "authorization header must use Bearer scheme")
77 return
78 }
79 token := strings.TrimPrefix(auth, "Bearer ")
80
81 // Validate token
82 claims, err := authSvc.ValidateToken(ctx, token)
83 if err != nil {
84 log.Warn().Err(err).Msg("invalid admin token")
85 sendUnauthorized(w, "invalid token")
86 return
87 }
88
89 // Check if token has admin scopes
90 hasAdminScope := false
91 for _, scope := range claims.Scopes {
92 if strings.HasPrefix(scope, domain.ScopeTypeAdmin+":") {
93 hasAdminScope = true
94 break
95 }
96 }
97
98 if !hasAdminScope {
99 log.Warn().Str("subject", claims.Subject).Msg("token missing admin scopes")
100 sendForbidden(w, "admin scope required")
101 return
102 }
103
104 // Add claims to context for downstream handlers
105 ctx = context.WithValue(ctx, domain.ContextKeyScopes, claims.Scopes)
106 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)
107 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)
108
109 next.ServeHTTP(w, r.WithContext(ctx))
110 })
111 }
112}
113
114// RequireScope creates middleware that checks for a specific admin scope.
115func RequireScope(resource, action string) func(http.Handler) http.Handler {
116 return func(next http.Handler) http.Handler {
117 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118 ctx := r.Context()
119
120 // Get scopes from context
121 scopes, ok := ctx.Value(domain.ContextKeyScopes).([]string)
122 if !ok {
123 sendForbidden(w, "no scopes in context")
124 return
125 }
126
127 // Check if user has required scope
128 if !domain.HasAdminAccess(scopes, resource, action) {
129 sendForbidden(w, "insufficient permissions")
130 return
131 }
132
133 next.ServeHTTP(w, r)
134 })
135 }
136}
137
138// GetSubject retrieves the authenticated subject from the context.
139func GetSubject(ctx context.Context) string {
140 subject, _ := ctx.Value(domain.ContextKeySubject).(string)
141 return subject
142}
143
144// GetScopes retrieves the token scopes from the context.
145func GetScopes(ctx context.Context) []string {
146 scopes, _ := ctx.Value(domain.ContextKeyScopes).([]string)
147 return scopes
148}
149
150// HasAccess checks if the context has access to the given resource and action.
151func HasAccess(ctx context.Context, resource, action string) bool {
152 scopes := GetScopes(ctx)
153 return domain.HasAdminAccess(scopes, resource, action)
154}
155
156func sendUnauthorized(w http.ResponseWriter, message string) {
157 w.Header().Set("Content-Type", "application/json")
158 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
159 w.WriteHeader(http.StatusUnauthorized)
160 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
161}
162
163func sendForbidden(w http.ResponseWriter, message string) {
164 w.Header().Set("Content-Type", "application/json")
165 w.WriteHeader(http.StatusForbidden)
166 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
167}