// Package api - Middleware for authentication and authorization package api import ( "context" "net/http" "net/url" "strings" "github.com/Grace-Solutions/OrchestrAD/internal/auth" "github.com/Grace-Solutions/OrchestrAD/internal/models" ) type contextKey string const userContextKey contextKey = "user" // scopeContextKey carries the API key scope ("read"/"readwrite"); empty for a // session, which has full access. const scopeContextKey contextKey = "scope" // AuthMiddleware validates session tokens func AuthMiddleware(authService *auth.Service) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := extractToken(r) if token == "" { WriteError(w, http.StatusUnauthorized, ErrCodeUnauthorized, "Authentication required") return } user, scope, err := validateAuth(authService, r, token) if err != nil { WriteError(w, http.StatusUnauthorized, ErrCodeUnauthorized, "Invalid or expired token") return } // A read-scoped API key may only perform safe (read) requests. if scope == "read" && !isReadMethod(r.Method) { WriteError(w, http.StatusForbidden, ErrCodeForbidden, "This API key is read-only") return } ctx := context.WithValue(r.Context(), userContextKey, user) ctx = context.WithValue(ctx, scopeContextKey, scope) next.ServeHTTP(w, r.WithContext(ctx)) }) } } // DocsPageAuthMiddleware protects the human-facing docs page. Unlike // AuthMiddleware it answers an unauthenticated browser with a redirect to the // login page (which returns here afterwards) instead of a JSON 401. func DocsPageAuthMiddleware(authService *auth.Service) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := extractToken(r) var user *models.User var scope string if token != "" { user, scope, _ = validateAuth(authService, r, token) } if user == nil { http.Redirect(w, r, "/login?redirect="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound) return } ctx := context.WithValue(r.Context(), userContextKey, user) ctx = context.WithValue(ctx, scopeContextKey, scope) next.ServeHTTP(w, r.WithContext(ctx)) }) } } // OptionalAuthMiddleware adds user to context if authenticated, but doesn't require it func OptionalAuthMiddleware(authService *auth.Service) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := extractToken(r) if token != "" { user, _, err := validateAuth(authService, r, token) if err == nil { ctx := context.WithValue(r.Context(), userContextKey, user) r = r.WithContext(ctx) } } next.ServeHTTP(w, r) }) } } // RequireRole creates middleware that requires a specific role func RequireRole(roles ...string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user := GetUserFromContext(r.Context()) if user == nil { WriteError(w, http.StatusUnauthorized, ErrCodeUnauthorized, "Authentication required") return } if !hasAnyRole(user, roles) { WriteError(w, http.StatusForbidden, ErrCodeForbidden, "Insufficient permissions") return } next.ServeHTTP(w, r) }) } } // RequireAdmin is a convenience middleware for admin-only routes func RequireAdmin() func(http.Handler) http.Handler { return RequireRole("SuperAdmin", "Admin") } // RequireOperator is a convenience middleware for operator-level routes func RequireOperator() func(http.Handler) http.Handler { return RequireRole("SuperAdmin", "Admin", "Operator") } // GetUserFromContext retrieves the authenticated user from context func GetUserFromContext(ctx context.Context) *models.User { user, ok := ctx.Value(userContextKey).(*models.User) if !ok { return nil } return user } // GetScopeFromContext returns the caller's API key scope ("read"/"readwrite"), // or "" for a session (full access). func GetScopeFromContext(ctx context.Context) string { scope, _ := ctx.Value(scopeContextKey).(string) return scope } // validateAuth resolves the caller to a user and, for API-key auth, the key's // scope ("read"/"readwrite"; empty for a session, which is full access). A token // from the X-API-Key header is validated as an API key; otherwise it is // validated as a session token, falling back to API-key validation so a key // sent as a bearer token also works. func validateAuth(authService *auth.Service, r *http.Request, token string) (*models.User, string, error) { if r.Header.Get("X-API-Key") != "" { return authService.ValidateAPIKey(token) } user, err := authService.ValidateSession(token) if err != nil { if apiUser, scope, apiErr := authService.ValidateAPIKey(token); apiErr == nil { return apiUser, scope, nil } return nil, "", err } return user, "", nil } func isReadMethod(method string) bool { return method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions } func extractToken(r *http.Request) string { // Check Authorization header auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "Bearer ") { return auth[7:] } // Check X-API-Key header for API key auth if apiKey := r.Header.Get("X-API-Key"); apiKey != "" { return apiKey } // Check cookie (for browser sessions) cookie, err := r.Cookie("session") if err == nil && cookie.Value != "" { return cookie.Value } return "" } func hasAnyRole(user *models.User, roles []string) bool { for _, userRole := range user.Roles { for _, required := range roles { if userRole.Name == required { return true } } } return false } // CSRF protection lives in csrf.go: see CSRF.Middleware, which validates // signed tokens instead of merely checking that a header is present.