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 // SECURITY: Reject long-lived (stored) tokens — they must be exchanged
90 // for ephemeral access tokens via /auth/token first.
91 if !claims.IsEphemeral {
92 log.Warn().
93 Str("subject", claims.Subject).
94 Str("path", r.URL.Path).
95 Msg("long-lived token rejected on admin endpoint")
96 sendUnauthorized(w, domain.ErrLongLivedToken.Error())
97 return
98 }
99
100 // Check if token has admin scopes
101 hasAdminScope := false
102 for _, scope := range claims.Scopes {
103 if strings.HasPrefix(scope, domain.ScopeTypeAdmin+":") {
104 hasAdminScope = true
105 break
106 }
107 }
108
109 if !hasAdminScope {
110 log.Warn().Str("subject", claims.Subject).Msg("token missing admin scopes")
111 sendForbidden(w, "admin scope required")
112 return
113 }
114
115 // Add claims to context for downstream handlers
116 ctx = context.WithValue(ctx, domain.ContextKeyScopes, claims.Scopes)
117 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)
118 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)
119
120 // Prevent intermediaries from caching admin API responses.
121 setNoStoreHeaders(w)
122
123 next.ServeHTTP(w, r.WithContext(ctx))
124 })
125 }
126}
127
128// RequireScope creates middleware that checks for a specific admin scope.
129func RequireScope(resource, action string) func(http.Handler) http.Handler {
130 return func(next http.Handler) http.Handler {
131 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
132 ctx := r.Context()
133
134 // Get scopes from context
135 scopes, ok := ctx.Value(domain.ContextKeyScopes).([]string)
136 if !ok {
137 sendForbidden(w, "no scopes in context")
138 return
139 }
140
141 // Check if user has required scope
142 if !domain.HasAdminAccess(scopes, resource, action) {
143 sendForbidden(w, "insufficient permissions")
144 return
145 }
146
147 next.ServeHTTP(w, r)
148 })
149 }
150}
151
152// GetSubject retrieves the authenticated subject from the context.
153func GetSubject(ctx context.Context) string {
154 subject, _ := ctx.Value(domain.ContextKeySubject).(string)
155 return subject
156}
157
158// GetScopes retrieves the token scopes from the context.
159func GetScopes(ctx context.Context) []string {
160 scopes, _ := ctx.Value(domain.ContextKeyScopes).([]string)
161 return scopes
162}
163
164// HasAccess checks if the context has access to the given resource and action.
165func HasAccess(ctx context.Context, resource, action string) bool {
166 scopes := GetScopes(ctx)
167 return domain.HasAdminAccess(scopes, resource, action)
168}
169
170func sendUnauthorized(w http.ResponseWriter, message string) {
171 setAdminJSONHeaders(w)
172 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
173 w.WriteHeader(http.StatusUnauthorized)
174 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
175}
176
177func sendForbidden(w http.ResponseWriter, message string) {
178 setAdminJSONHeaders(w)
179 w.WriteHeader(http.StatusForbidden)
180 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
181}
182
183func setAdminJSONHeaders(w http.ResponseWriter) {
184 w.Header().Set("Content-Type", "application/json")
185 setNoStoreHeaders(w)
186}
187
188func setNoStoreHeaders(w http.ResponseWriter) {
189 w.Header().Set("Cache-Control", "no-store")
190 w.Header().Set("Pragma", "no-cache")
191}