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