Skip to content

Commit 274a930

Browse files
authored
Merge pull request #16 from github/copilot/fix-15
feat: support custom auth plugins
2 parents 09dab75 + e7b996c commit 274a930

31 files changed

+652
-77
lines changed

docs/auth_plugins.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Auth Plugins
2+
3+
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.
4+
5+
In your global configuration file (e.g. `hooks.yml`) you would likely set `auth_plugin_dir` to something like `./plugins/auth`.
6+
7+
Here is an example snippet of how you might configure the global settings in `hooks.yml`:
8+
9+
```yaml
10+
# hooks.yml
11+
auth_plugin_dir: ./plugins/auth # Directory where custom auth plugins are stored
12+
```
13+
14+
Then place your custom auth plugin in the `./plugins/auth` directory, for example `./plugins/auth/some_cool_auth_plugin.rb`.
15+
16+
```ruby
17+
# frozen_string_literal: true
18+
# Example custom auth plugin implementation
19+
module Hooks
20+
module Plugins
21+
module Auth
22+
class SomeCoolAuthPlugin < Base
23+
def self.valid?(payload:, headers:, config:)
24+
# Get the secret from environment variable
25+
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`)
26+
27+
# Get the authorization header (case-insensitive)
28+
auth_header = nil
29+
headers.each do |key, value|
30+
if key.downcase == "authorization"
31+
auth_header = value
32+
break
33+
end
34+
end
35+
36+
# Check if the header matches our expected format
37+
return false unless auth_header
38+
39+
# Extract the token from "Bearer <token>" format
40+
return false unless auth_header.start_with?("Bearer ")
41+
42+
token = auth_header[7..-1] # Remove "Bearer " prefix
43+
44+
# Simple token comparison (in practice, this might be more complex)
45+
token == secret
46+
end
47+
end
48+
end
49+
end
50+
end
51+
```
52+
53+
Then you could create a new endpoint configuration that references this plugin:
54+
55+
```yaml
56+
path: /example
57+
handler: CoolNewHandler
58+
59+
auth:
60+
type: some_cool_auth_plugin # using the newly created auth plugin as seen above
61+
secret_env_key: SUPER_COOL_SECRET # the name of the environment variable containing the shared secret - used by `fetch_secret(config)` in the plugin
62+
header: Authorization
63+
```

docs/design.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Note: The `hooks` gem name is already taken on RubyGems, so this project is name
3232

3333
2. **Plugin Architecture**
3434

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

521521
### Core Classes
522522

523-
#### `Hooks::Handlers::Base`
523+
#### `Hooks::Plugins::Handlers::Base`
524524

525525
Base class for all webhook handlers.
526526

527527
```ruby
528-
class MyHandler < Hooks::Handlers::Base
528+
class MyHandler < Hooks::Plugins::Handlers::Base
529529
# @param payload [Hash, String] Parsed request body or raw string
530530
# @param headers [Hash<String, String>] HTTP headers
531531
# @param config [Hash] Merged endpoint configuration

lib/hooks.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22

33
require_relative "hooks/version"
44
require_relative "hooks/core/builder"
5-
require_relative "hooks/handlers/base"
65

7-
# Load all plugins (request validators, lifecycle hooks, etc.)
6+
# Load all plugins (auth plugins, handler plugins, lifecycle hooks, etc.)
87
Dir[File.join(__dir__, "hooks/plugins/**/*.rb")].sort.each do |file|
98
require file
109
end

lib/hooks/app/api.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
require "securerandom"
66
require_relative "helpers"
77
require_relative "auth/auth"
8-
require_relative "../handlers/base"
9-
require_relative "../handlers/default"
8+
require_relative "../plugins/handlers/base"
9+
require_relative "../plugins/handlers/default"
1010
require_relative "../core/logger_factory"
1111
require_relative "../core/log"
1212

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

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

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

7575
response = handler.call(

lib/hooks/app/auth/auth.rb

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ module Auth
1313
# @param payload [String, Hash] The request payload to authenticate.
1414
# @param headers [Hash] The request headers.
1515
# @param endpoint_config [Hash] The endpoint configuration, must include :auth key.
16+
# @param global_config [Hash] The global configuration (optional, needed for custom auth plugins).
1617
# @raise [StandardError] Raises error if authentication fails or is misconfigured.
1718
# @return [void]
1819
# @note This method will halt execution with an error if authentication fails.
19-
def validate_auth!(payload, headers, endpoint_config)
20+
def validate_auth!(payload, headers, endpoint_config, global_config = {})
2021
auth_config = endpoint_config[:auth]
2122

2223
# Security: Ensure auth type is present and valid
@@ -35,7 +36,24 @@ def validate_auth!(payload, headers, endpoint_config)
3536
when "shared_secret"
3637
auth_class = Plugins::Auth::SharedSecret
3738
else
38-
error!("Custom validators not implemented in POC", 500)
39+
# Try to load custom auth plugin if auth_plugin_dir is configured
40+
if global_config[:auth_plugin_dir]
41+
# Convert auth_type to CamelCase class name
42+
auth_plugin_class_name = auth_type.split("_").map(&:capitalize).join("")
43+
44+
# Validate the converted class name before attempting to load
45+
unless valid_auth_plugin_class_name?(auth_plugin_class_name)
46+
error!("invalid auth plugin type '#{auth_type}'", 400)
47+
end
48+
49+
begin
50+
auth_class = load_auth_plugin(auth_plugin_class_name, global_config[:auth_plugin_dir])
51+
rescue => e
52+
error!("failed to load custom auth plugin '#{auth_type}': #{e.message}", 500)
53+
end
54+
else
55+
error!("unsupported auth type '#{auth_type}' due to auth_plugin_dir not being set", 400)
56+
end
3957
end
4058

4159
unless auth_class.valid?(

lib/hooks/app/endpoints/catchall.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

33
require "grape"
4-
require_relative "../../handlers/default"
4+
require_relative "../../plugins/handlers/default"
55
require_relative "../helpers"
66

77
module Hooks

lib/hooks/app/helpers.rb

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ def load_handler(handler_class_name, handler_dir)
9292
handler_class = Object.const_get(handler_class_name)
9393

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

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

107+
# Load auth plugin class
108+
#
109+
# @param auth_plugin_class_name [String] The name of the auth plugin class to load
110+
# @param auth_plugin_dir [String] The directory containing auth plugin files
111+
# @return [Class] The loaded auth plugin class
112+
# @raise [LoadError] If the auth plugin file or class cannot be found
113+
# @raise [StandardError] Halts with error if auth plugin cannot be loaded
114+
def load_auth_plugin(auth_plugin_class_name, auth_plugin_dir)
115+
# Security: Validate auth plugin class name to prevent arbitrary class loading
116+
unless valid_auth_plugin_class_name?(auth_plugin_class_name)
117+
error!("invalid auth plugin class name: #{auth_plugin_class_name}", 400)
118+
end
119+
120+
# Convert class name to file name (e.g., SomeCoolAuthPlugin -> some_cool_auth_plugin.rb)
121+
file_name = auth_plugin_class_name.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "") + ".rb"
122+
file_path = File.join(auth_plugin_dir, file_name)
123+
124+
# Security: Ensure the file path doesn't escape the auth plugin directory
125+
normalized_auth_plugin_dir = Pathname.new(File.expand_path(auth_plugin_dir))
126+
normalized_file_path = Pathname.new(File.expand_path(file_path))
127+
unless normalized_file_path.descend.any? { |path| path == normalized_auth_plugin_dir }
128+
error!("auth plugin path outside of auth plugin directory", 400)
129+
end
130+
131+
if File.exist?(file_path)
132+
require file_path
133+
auth_plugin_class = Object.const_get("Hooks::Plugins::Auth::#{auth_plugin_class_name}")
134+
135+
# Security: Ensure the loaded class inherits from the expected base class
136+
unless auth_plugin_class < Hooks::Plugins::Auth::Base
137+
error!("auth plugin class must inherit from Hooks::Plugins::Auth::Base", 400)
138+
end
139+
140+
auth_plugin_class
141+
else
142+
error!("Auth plugin #{auth_plugin_class_name} not found at #{file_path}", 500)
143+
end
144+
rescue => e
145+
error!("failed to load auth plugin: #{e.message}", 500)
146+
end
147+
107148
private
108149

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

171+
# Validate that an auth plugin class name is safe to load
172+
#
173+
# @param class_name [String] The class name to validate
174+
# @return [Boolean] true if the class name is safe, false otherwise
175+
def valid_auth_plugin_class_name?(class_name)
176+
# Must be a string
177+
return false unless class_name.is_a?(String)
178+
179+
# Must not be empty or only whitespace
180+
return false if class_name.strip.empty?
181+
182+
# Must match a safe pattern: alphanumeric + underscore, starting with uppercase
183+
# Examples: MyAuthPlugin, SomeCoolAuthPlugin, CustomAuth
184+
return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
185+
186+
# Must not be a system/built-in class name
187+
return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name)
188+
189+
true
190+
end
191+
130192
# Determine HTTP error code from exception
131193
#
132194
# @param exception [Exception] The exception to map to an HTTP status code

lib/hooks/core/builder.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ def build
4444

4545
# Build and return Grape API class
4646
Hooks::App::API.create(
47-
config: config,
48-
endpoints: endpoints,
47+
config:,
48+
endpoints:,
4949
log: @log
5050
)
5151
end

lib/hooks/core/config_loader.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ module Core
88
# Loads and merges configuration from files and environment variables
99
class ConfigLoader
1010
DEFAULT_CONFIG = {
11-
handler_dir: "./handlers",
11+
handler_plugin_dir: "./plugins/handlers",
12+
auth_plugin_dir: "./plugins/auth",
1213
log_level: "info",
1314
request_limit: 1_048_576,
1415
request_timeout: 30,
@@ -104,7 +105,8 @@ def self.load_env_config
104105
env_config = {}
105106

106107
env_mappings = {
107-
"HOOKS_HANDLER_DIR" => :handler_dir,
108+
"HOOKS_HANDLER_PLUGIN_DIR" => :handler_plugin_dir,
109+
"HOOKS_AUTH_PLUGIN_DIR" => :auth_plugin_dir,
108110
"HOOKS_LOG_LEVEL" => :log_level,
109111
"HOOKS_REQUEST_LIMIT" => :request_limit,
110112
"HOOKS_REQUEST_TIMEOUT" => :request_timeout,

lib/hooks/core/config_validator.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ class ValidationError < StandardError; end
1212

1313
# Global configuration schema
1414
GLOBAL_CONFIG_SCHEMA = Dry::Schema.Params do
15-
optional(:handler_dir).filled(:string)
15+
optional(:handler_dir).filled(:string) # For backward compatibility
16+
optional(:handler_plugin_dir).filled(:string)
17+
optional(:auth_plugin_dir).maybe(:string)
1618
optional(:log_level).filled(:string, included_in?: %w[debug info warn error])
1719
optional(:request_limit).filled(:integer, gt?: 0)
1820
optional(:request_timeout).filled(:integer, gt?: 0)

0 commit comments

Comments
 (0)