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 setAdminJSONHeaders(w)
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 setAdminJSONHeaders(w)
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 // Prevent intermediaries from storing admin API responses, especially
110 // when a rotated bearer token is returned in X-Gordon-Token.
111 setNoStoreHeaders(w)
112
113 // Attempt to slide token expiry. Non-fatal if it fails.
114 // The new token is returned in X-Gordon-Token so the CLI can persist it atomically.
115 if newToken, extErr := authSvc.ExtendToken(ctx, token); extErr != nil {
116 log.Warn().Err(extErr).Str("subject", claims.Subject).Msg("token extension failed")
117 } else if newToken != token {
118 w.Header().Set("X-Gordon-Token", newToken)
119 }
120
121 next.ServeHTTP(w, r.WithContext(ctx))
122 })
123 }
124}
125
126// RequireScope creates middleware that checks for a specific admin scope.
127func RequireScope(resource, action string) func(http.Handler) http.Handler {
128 return func(next http.Handler) http.Handler {
129 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
130 ctx := r.Context()
131
132 // Get scopes from context
133 scopes, ok := ctx.Value(domain.ContextKeyScopes).([]string)
134 if !ok {
135 sendForbidden(w, "no scopes in context")
136 return
137 }
138
139 // Check if user has required scope
140 if !domain.HasAdminAccess(scopes, resource, action) {
141 sendForbidden(w, "insufficient permissions")
142 return
143 }
144
145 next.ServeHTTP(w, r)
146 })
147 }
148}
149
150// GetSubject retrieves the authenticated subject from the context.
151func GetSubject(ctx context.Context) string {
152 subject, _ := ctx.Value(domain.ContextKeySubject).(string)
153 return subject
154}
155
156// GetScopes retrieves the token scopes from the context.
157func GetScopes(ctx context.Context) []string {
158 scopes, _ := ctx.Value(domain.ContextKeyScopes).([]string)
159 return scopes
160}
161
162// HasAccess checks if the context has access to the given resource and action.
163func HasAccess(ctx context.Context, resource, action string) bool {
164 scopes := GetScopes(ctx)
165 return domain.HasAdminAccess(scopes, resource, action)
166}
167
168func sendUnauthorized(w http.ResponseWriter, message string) {
169 setAdminJSONHeaders(w)
170 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
171 w.WriteHeader(http.StatusUnauthorized)
172 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
173}
174
175func sendForbidden(w http.ResponseWriter, message string) {
176 setAdminJSONHeaders(w)
177 w.WriteHeader(http.StatusForbidden)
178 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
179}
180
181func setAdminJSONHeaders(w http.ResponseWriter) {
182 w.Header().Set("Content-Type", "application/json")
183 setNoStoreHeaders(w)
184}
185
186func setNoStoreHeaders(w http.ResponseWriter) {
187 w.Header().Set("Cache-Control", "no-store")
188 w.Header().Set("Pragma", "no-cache")
189}