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