Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions docs/auth_plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Auth Plugins

This document provides an example of how to implement a custom authentication plugin for a hypothetical system. The plugin checks for a specific authorization header and validates it against a secret stored in an environment variable.

In your global configuration file (e.g. `hooks.yml`) you would likely set `auth_plugin_dir` to something like `./plugins/auth`.

Here is an example snippet of how you might configure the global settings in `hooks.yml`:

```yaml
# hooks.yml
auth_plugin_dir: ./plugins/auth # Directory where custom auth plugins are stored
```

Then place your custom auth plugin in the `./plugins/auth` directory, for example `./plugins/auth/some_cool_auth_plugin.rb`.

```ruby
# frozen_string_literal: true
# Example custom auth plugin implementation
module Hooks
module Plugins
module Auth
class SomeCoolAuthPlugin < Base
def self.valid?(payload:, headers:, config:)
# Get the secret from environment variable
secret = fetch_secret(config) # by default, this will fetch the value of the environment variable specified in the config (e.g. SUPER_COOL_SECRET as defined by `secret_env_key`)

# Get the authorization header (case-insensitive)
auth_header = nil
headers.each do |key, value|
if key.downcase == "authorization"
auth_header = value
break
end
end

# Check if the header matches our expected format
return false unless auth_header

# Extract the token from "Bearer <token>" format
return false unless auth_header.start_with?("Bearer ")

token = auth_header[7..-1] # Remove "Bearer " prefix

# Simple token comparison (in practice, this might be more complex)
token == secret
end
end
end
end
end
```

Then you could create a new endpoint configuration that references this plugin:

```yaml
path: /example
handler: CoolNewHandler

auth:
type: some_cool_auth_plugin # using the newly created auth plugin as seen above
secret_env_key: SUPER_COOL_SECRET # the name of the environment variable containing the shared secret - used by `fetch_secret(config)` in the plugin
header: Authorization
```
8 changes: 4 additions & 4 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Note: The `hooks` gem name is already taken on RubyGems, so this project is name

2. **Plugin Architecture**

* **Team Handlers**: `class MyHandler < Hooks::Handlers::Base`
* **Team Handlers**: `class MyHandler < Hooks::Plugins::Handlers::Base`
* Must implement `#call(payload:, headers:, config:)` method
* `payload`: parsed request body (JSON Hash or raw String)
* `headers`: HTTP headers as Hash with string keys
Expand Down Expand Up @@ -142,7 +142,7 @@ lib/hooks/
│ ├── logger_factory.rb # Structured JSON logger + context enrichment
├── handlers/
│ └── base.rb # `Hooks::Handlers::Base` interface: defines #call
│ └── base.rb # `Hooks::Plugins::Handlers::Base` interface: defines #call
├── plugins/
│ ├── lifecycle.rb # `Hooks::Plugins::Lifecycle` hooks (on_request, response, error)
Expand Down Expand Up @@ -520,12 +520,12 @@ The health endpoint provides comprehensive status information for load balancers

### Core Classes

#### `Hooks::Handlers::Base`
#### `Hooks::Plugins::Handlers::Base`

Base class for all webhook handlers.

```ruby
class MyHandler < Hooks::Handlers::Base
class MyHandler < Hooks::Plugins::Handlers::Base
# @param payload [Hash, String] Parsed request body or raw string
# @param headers [Hash<String, String>] HTTP headers
# @param config [Hash] Merged endpoint configuration
Expand Down
3 changes: 1 addition & 2 deletions lib/hooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@

require_relative "hooks/version"
require_relative "hooks/core/builder"
require_relative "hooks/handlers/base"

# Load all plugins (request validators, lifecycle hooks, etc.)
# Load all plugins (auth plugins, handler plugins, lifecycle hooks, etc.)
Dir[File.join(__dir__, "hooks/plugins/**/*.rb")].sort.each do |file|
require file
end
Expand Down
8 changes: 4 additions & 4 deletions lib/hooks/app/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
require "securerandom"
require_relative "helpers"
require_relative "auth/auth"
require_relative "../handlers/base"
require_relative "../handlers/default"
require_relative "../plugins/handlers/base"
require_relative "../plugins/handlers/default"
require_relative "../core/logger_factory"
require_relative "../core/log"

Expand Down Expand Up @@ -65,11 +65,11 @@ def self.create(config:, endpoints:, log:)

if endpoint_config[:auth]
log.info "validating request (id: #{request_id}, handler: #{handler_class_name})"
validate_auth!(raw_body, headers, endpoint_config)
validate_auth!(raw_body, headers, endpoint_config, config)
end

payload = parse_payload(raw_body, headers, symbolize: config[:symbolize_payload])
handler = load_handler(handler_class_name, config[:handler_dir])
handler = load_handler(handler_class_name, config[:handler_plugin_dir])
normalized_headers = config[:normalize_headers] ? Hooks::Utils::Normalize.headers(headers) : headers

response = handler.call(
Expand Down
22 changes: 20 additions & 2 deletions lib/hooks/app/auth/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ module Auth
# @param payload [String, Hash] The request payload to authenticate.
# @param headers [Hash] The request headers.
# @param endpoint_config [Hash] The endpoint configuration, must include :auth key.
# @param global_config [Hash] The global configuration (optional, needed for custom auth plugins).
# @raise [StandardError] Raises error if authentication fails or is misconfigured.
# @return [void]
# @note This method will halt execution with an error if authentication fails.
def validate_auth!(payload, headers, endpoint_config)
def validate_auth!(payload, headers, endpoint_config, global_config = {})
auth_config = endpoint_config[:auth]

# Security: Ensure auth type is present and valid
Expand All @@ -35,7 +36,24 @@ def validate_auth!(payload, headers, endpoint_config)
when "shared_secret"
auth_class = Plugins::Auth::SharedSecret
else
error!("Custom validators not implemented in POC", 500)
# Try to load custom auth plugin if auth_plugin_dir is configured
if global_config[:auth_plugin_dir]
# Convert auth_type to CamelCase class name
auth_plugin_class_name = auth_type.split("_").map(&:capitalize).join("")

# Validate the converted class name before attempting to load
unless valid_auth_plugin_class_name?(auth_plugin_class_name)
error!("invalid auth plugin type '#{auth_type}'", 400)
end

begin
auth_class = load_auth_plugin(auth_plugin_class_name, global_config[:auth_plugin_dir])
rescue => e
error!("failed to load custom auth plugin '#{auth_type}': #{e.message}", 500)
end
else
error!("unsupported auth type '#{auth_type}' due to auth_plugin_dir not being set", 400)
end
end

unless auth_class.valid?(
Expand Down
2 changes: 1 addition & 1 deletion lib/hooks/app/endpoints/catchall.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

require "grape"
require_relative "../../handlers/default"
require_relative "../../plugins/handlers/default"
require_relative "../helpers"

module Hooks
Expand Down
66 changes: 64 additions & 2 deletions lib/hooks/app/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ def load_handler(handler_class_name, handler_dir)
handler_class = Object.const_get(handler_class_name)

# Security: Ensure the loaded class inherits from the expected base class
unless handler_class < Hooks::Handlers::Base
error!("handler class must inherit from Hooks::Handlers::Base", 400)
unless handler_class < Hooks::Plugins::Handlers::Base
error!("handler class must inherit from Hooks::Plugins::Handlers::Base", 400)
end

handler_class.new
Expand All @@ -104,6 +104,47 @@ def load_handler(handler_class_name, handler_dir)
error!("failed to load handler: #{e.message}", 500)
end

# Load auth plugin class
#
# @param auth_plugin_class_name [String] The name of the auth plugin class to load
# @param auth_plugin_dir [String] The directory containing auth plugin files
# @return [Class] The loaded auth plugin class
# @raise [LoadError] If the auth plugin file or class cannot be found
# @raise [StandardError] Halts with error if auth plugin cannot be loaded
def load_auth_plugin(auth_plugin_class_name, auth_plugin_dir)
# Security: Validate auth plugin class name to prevent arbitrary class loading
unless valid_auth_plugin_class_name?(auth_plugin_class_name)
error!("invalid auth plugin class name: #{auth_plugin_class_name}", 400)
end

# Convert class name to file name (e.g., SomeCoolAuthPlugin -> some_cool_auth_plugin.rb)
file_name = auth_plugin_class_name.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "") + ".rb"
Comment thread
GrantBirki marked this conversation as resolved.
file_path = File.join(auth_plugin_dir, file_name)

# Security: Ensure the file path doesn't escape the auth plugin directory
normalized_auth_plugin_dir = Pathname.new(File.expand_path(auth_plugin_dir))
normalized_file_path = Pathname.new(File.expand_path(file_path))
unless normalized_file_path.descend.any? { |path| path == normalized_auth_plugin_dir }
error!("auth plugin path outside of auth plugin directory", 400)
end

if File.exist?(file_path)
require file_path
auth_plugin_class = Object.const_get("Hooks::Plugins::Auth::#{auth_plugin_class_name}")

# Security: Ensure the loaded class inherits from the expected base class
unless auth_plugin_class < Hooks::Plugins::Auth::Base
error!("auth plugin class must inherit from Hooks::Plugins::Auth::Base", 400)
end

auth_plugin_class
else
error!("Auth plugin #{auth_plugin_class_name} not found at #{file_path}", 500)
end
rescue => e
error!("failed to load auth plugin: #{e.message}", 500)
end

private

# Validate that a handler class name is safe to load
Expand All @@ -127,6 +168,27 @@ def valid_handler_class_name?(class_name)
true
end

# Validate that an auth plugin class name is safe to load
#
# @param class_name [String] The class name to validate
# @return [Boolean] true if the class name is safe, false otherwise
def valid_auth_plugin_class_name?(class_name)
# Must be a string
return false unless class_name.is_a?(String)

# Must not be empty or only whitespace
return false if class_name.strip.empty?

# Must match a safe pattern: alphanumeric + underscore, starting with uppercase
# Examples: MyAuthPlugin, SomeCoolAuthPlugin, CustomAuth
return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)

# Must not be a system/built-in class name
return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name)

true
end

# Determine HTTP error code from exception
#
# @param exception [Exception] The exception to map to an HTTP status code
Expand Down
4 changes: 2 additions & 2 deletions lib/hooks/core/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ def build

# Build and return Grape API class
Hooks::App::API.create(
config: config,
endpoints: endpoints,
config:,
endpoints:,
log: @log
)
end
Expand Down
6 changes: 4 additions & 2 deletions lib/hooks/core/config_loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ module Core
# Loads and merges configuration from files and environment variables
class ConfigLoader
DEFAULT_CONFIG = {
handler_dir: "./handlers",
handler_plugin_dir: "./plugins/handlers",
auth_plugin_dir: "./plugins/auth",
log_level: "info",
request_limit: 1_048_576,
request_timeout: 30,
Expand Down Expand Up @@ -104,7 +105,8 @@ def self.load_env_config
env_config = {}

env_mappings = {
"HOOKS_HANDLER_DIR" => :handler_dir,
"HOOKS_HANDLER_PLUGIN_DIR" => :handler_plugin_dir,
"HOOKS_AUTH_PLUGIN_DIR" => :auth_plugin_dir,
"HOOKS_LOG_LEVEL" => :log_level,
"HOOKS_REQUEST_LIMIT" => :request_limit,
"HOOKS_REQUEST_TIMEOUT" => :request_timeout,
Expand Down
4 changes: 3 additions & 1 deletion lib/hooks/core/config_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ class ValidationError < StandardError; end

# Global configuration schema
GLOBAL_CONFIG_SCHEMA = Dry::Schema.Params do
optional(:handler_dir).filled(:string)
optional(:handler_dir).filled(:string) # For backward compatibility
optional(:handler_plugin_dir).filled(:string)
optional(:auth_plugin_dir).maybe(:string)
optional(:log_level).filled(:string, included_in?: %w[debug info warn error])
optional(:request_limit).filled(:integer, gt?: 0)
optional(:request_timeout).filled(:integer, gt?: 0)
Expand Down
33 changes: 0 additions & 33 deletions lib/hooks/handlers/base.rb

This file was deleted.

35 changes: 35 additions & 0 deletions lib/hooks/plugins/handlers/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

module Hooks
module Plugins
module Handlers
# Base class for all webhook handlers
#
# All custom handlers must inherit from this class and implement the #call method
class Base
# Process a webhook request
#
# @param payload [Hash, String] Parsed request body (JSON Hash) or raw string
# @param headers [Hash<String, String>] HTTP headers
# @param config [Hash] Merged endpoint configuration including opts section
# @return [Hash, String, nil] Response body (will be auto-converted to JSON)
# @raise [NotImplementedError] if not implemented by subclass
def call(payload:, headers:, config:)
raise NotImplementedError, "Handler must implement #call method"
end

# Short logger accessor for all subclasses
# @return [Hooks::Log] Logger instance
#
# Provides a convenient way for handlers to log messages without needing
# to reference the full Hooks::Log namespace.
#
# @example Logging an error in an inherited class
# log.error("oh no an error occured")
def log
Hooks::Log.instance
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# Default handler when no custom handler is found
# This handler simply acknowledges receipt of the webhook and shows a few of the built-in features
class DefaultHandler < Hooks::Handlers::Base
class DefaultHandler < Hooks::Plugins::Handlers::Base
def call(payload:, headers:, config:)

log.info("🔔 Default handler invoked for webhook 🔔")
Expand Down
Loading