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 // Check if auth is enabled.
55 // DESIGN NOTE: When auth is disabled, the admin API is intentionally
56 // accessible without credentials. This is the expected behavior for
57 // local/development setups where Gordon is not exposed to the internet.
58 // Users who disable auth accept this tradeoff explicitly in their config.
59 // For production deployments, auth should always be enabled.
60 if !authSvc.IsEnabled() {
61 log.Warn().
62 Str("path", r.URL.Path).
63 Str("method", r.Method).
64 Str("remote_addr", r.RemoteAddr).
65 Msg("auth disabled - allowing unauthenticated admin API access (intended for local use only)")
66 next.ServeHTTP(w, r)
67 return
68 }
69
70 // Extract token from Authorization header.
71 // SECURITY: Require the standard "Bearer" prefix per RFC 6750
72 // to prevent accidental token exposure in non-standard formats.
73 auth := r.Header.Get("Authorization")
74 if auth == "" {
75 sendUnauthorized(w, "missing authorization header")
76 return
77 }
78
79 if !strings.HasPrefix(auth, "Bearer ") {
80 sendUnauthorized(w, "authorization header must use Bearer scheme")
81 return
82 }
83 token := strings.TrimPrefix(auth, "Bearer ")
84
85 // Validate token
86 claims, err := authSvc.ValidateToken(ctx, token)
87 if err != nil {
88 log.Warn().Err(err).Msg("invalid admin token")
89 sendUnauthorized(w, "invalid token")
90 return
91 }
92
93 // Check if token has admin scopes
94 hasAdminScope := false
95 for _, scope := range claims.Scopes {
96 if strings.HasPrefix(scope, domain.ScopeTypeAdmin+":") {
97 hasAdminScope = true
98 break
99 }
100 }
101
102 if !hasAdminScope {
103 log.Warn().Str("subject", claims.Subject).Msg("token missing admin scopes")
104 sendForbidden(w, "admin scope required")
105 return
106 }
107
108 // Add claims to context for downstream handlers
109 ctx = context.WithValue(ctx, domain.ContextKeyScopes, claims.Scopes)
110 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)
111 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)
112
113 next.ServeHTTP(w, r.WithContext(ctx))
114 })
115 }
116}
117
118// RequireScope creates middleware that checks for a specific admin scope.
119func RequireScope(resource, action string) func(http.Handler) http.Handler {
120 return func(next http.Handler) http.Handler {
121 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122 ctx := r.Context()
123
124 // Get scopes from context
125 scopes, ok := ctx.Value(domain.ContextKeyScopes).([]string)
126 if !ok {
127 sendForbidden(w, "no scopes in context")
128 return
129 }
130
131 // Check if user has required scope
132 if !domain.HasAdminAccess(scopes, resource, action) {
133 sendForbidden(w, "insufficient permissions")
134 return
135 }
136
137 next.ServeHTTP(w, r)
138 })
139 }
140}
141
142// GetSubject retrieves the authenticated subject from the context.
143func GetSubject(ctx context.Context) string {
144 subject, _ := ctx.Value(domain.ContextKeySubject).(string)
145 return subject
146}
147
148// GetScopes retrieves the token scopes from the context.
149func GetScopes(ctx context.Context) []string {
150 scopes, _ := ctx.Value(domain.ContextKeyScopes).([]string)
151 return scopes
152}
153
154// HasAccess checks if the context has access to the given resource and action.
155func HasAccess(ctx context.Context, resource, action string) bool {
156 scopes := GetScopes(ctx)
157 return domain.HasAdminAccess(scopes, resource, action)
158}
159
160func sendUnauthorized(w http.ResponseWriter, message string) {
161 w.Header().Set("Content-Type", "application/json")
162 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
163 w.WriteHeader(http.StatusUnauthorized)
164 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
165}
166
167func sendForbidden(w http.ResponseWriter, message string) {
168 w.Header().Set("Content-Type", "application/json")
169 w.WriteHeader(http.StatusForbidden)
170 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
171}