Skip to content

Commit 66a90a7

Browse files
CopilotGrantBirki
andcommitted
feat: implement custom auth plugins support
Co-authored-by: GrantBirki <[email protected]>
1 parent 1e7fe22 commit 66a90a7

File tree

11 files changed

+563
-7
lines changed

11 files changed

+563
-7
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Sample configuration for Hooks webhook server with custom auth plugins
2+
handler_plugin_dir: ./spec/acceptance/handlers
3+
auth_plugin_dir: ./spec/acceptance/plugins/auth # NEW! Directory for custom auth plugins
4+
log_level: debug
5+
6+
# Request handling
7+
request_limit: 1048576 # 1MB max body size
8+
request_timeout: 15 # 15 seconds timeout
9+
10+
# Path configuration
11+
root_path: /webhooks
12+
health_path: /health
13+
version_path: /version
14+
15+
# Runtime behavior
16+
environment: development
17+
18+
# Available endpoints
19+
# Each endpoint configuration file should be placed in the endpoints directory
20+
endpoints_dir: ./spec/acceptance/config/endpoints
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
path: /example
2+
handler: CoolNewHandler
3+
4+
auth:
5+
type: some_cool_auth_plugin
6+
secret_env_key: SUPER_COOL_SECRET # the name of the environment variable containing the shared secret
7+
header: Authorization

docs/example_custom_auth_plugin.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# frozen_string_literal: true
2+
# Example custom auth plugin implementation
3+
module Hooks
4+
module Plugins
5+
module Auth
6+
class SomeCoolAuthPlugin < Base
7+
def self.valid?(payload:, headers:, config:)
8+
# Get the secret from environment variable
9+
secret = fetch_secret(config)
10+
11+
# Get the authorization header (case-insensitive)
12+
auth_header = nil
13+
headers.each do |key, value|
14+
if key.downcase == "authorization"
15+
auth_header = value
16+
break
17+
end
18+
end
19+
20+
# Check if the header matches our expected format
21+
return false unless auth_header
22+
23+
# Extract the token from "Bearer <token>" format
24+
return false unless auth_header.start_with?("Bearer ")
25+
26+
token = auth_header[7..-1] # Remove "Bearer " prefix
27+
28+
# Simple token comparison (in practice, this might be more complex)
29+
token == secret
30+
end
31+
end
32+
end
33+
end
34+
end

lib/hooks/app/api.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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!("Custom validators not implemented in POC", 500)
56+
end
3957
end
4058

4159
unless auth_class.valid?(

lib/hooks/app/helpers.rb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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/config_loader.rb

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ module Core
88
# Loads and merges configuration from files and environment variables
99
class ConfigLoader
1010
DEFAULT_CONFIG = {
11-
handler_dir: "./handlers",
11+
handler_dir: "./handlers", # For backward compatibility
12+
handler_plugin_dir: "./handlers",
13+
auth_plugin_dir: nil,
1214
log_level: "info",
1315
request_limit: 1_048_576,
1416
request_timeout: 30,
@@ -29,6 +31,7 @@ class ConfigLoader
2931
# @return [Hash] Merged configuration
3032
def self.load(config_path: nil)
3133
config = DEFAULT_CONFIG.dup
34+
default_handler_dir = config[:handler_dir]
3235

3336
# Load from file if path provided
3437
if config_path.is_a?(String) && File.exist?(config_path)
@@ -44,6 +47,19 @@ def self.load(config_path: nil)
4447
# Convert string keys to symbols for consistency
4548
config = symbolize_keys(config)
4649

50+
# Support backward compatibility for handler_dir <-> handler_plugin_dir
51+
# Check if values changed from default
52+
if config[:handler_plugin_dir] != default_handler_dir && config[:handler_dir] == default_handler_dir
53+
# Only handler_plugin_dir was changed, sync handler_dir
54+
config[:handler_dir] = config[:handler_plugin_dir]
55+
elsif config[:handler_dir] != default_handler_dir && config[:handler_plugin_dir] == default_handler_dir
56+
# Only handler_dir was changed, sync handler_plugin_dir
57+
config[:handler_plugin_dir] = config[:handler_dir]
58+
elsif config[:handler_plugin_dir] != default_handler_dir && config[:handler_dir] != default_handler_dir
59+
# Both changed, handler_plugin_dir takes precedence
60+
config[:handler_dir] = config[:handler_plugin_dir]
61+
end
62+
4763
if config[:environment] == "production"
4864
config[:production] = true
4965
else
@@ -104,7 +120,9 @@ def self.load_env_config
104120
env_config = {}
105121

106122
env_mappings = {
107-
"HOOKS_HANDLER_DIR" => :handler_dir,
123+
"HOOKS_HANDLER_DIR" => :handler_dir, # For backward compatibility
124+
"HOOKS_HANDLER_PLUGIN_DIR" => :handler_plugin_dir,
125+
"HOOKS_AUTH_PLUGIN_DIR" => :auth_plugin_dir,
108126
"HOOKS_LOG_LEVEL" => :log_level,
109127
"HOOKS_REQUEST_LIMIT" => :request_limit,
110128
"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)
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "../../../../spec_helper"
4+
5+
describe "Custom Auth Plugin Integration" do
6+
let(:custom_auth_plugin_dir) { "/tmp/test_auth_plugins" }
7+
let(:plugin_file_content) do
8+
<<~RUBY
9+
module Hooks
10+
module Plugins
11+
module Auth
12+
class SomeCoolAuthPlugin < Base
13+
def self.valid?(payload:, headers:, config:)
14+
# Mock implementation: check for specific header
15+
secret = fetch_secret(config)
16+
bearer_token = headers["authorization"]
17+
bearer_token == "Bearer \#{secret}"
18+
end
19+
end
20+
end
21+
end
22+
end
23+
RUBY
24+
end
25+
26+
let(:global_config) do
27+
{
28+
auth_plugin_dir: custom_auth_plugin_dir,
29+
handler_plugin_dir: "./spec/acceptance/handlers"
30+
}
31+
end
32+
33+
let(:endpoint_config) do
34+
{
35+
path: "/example",
36+
handler: "DefaultHandler",
37+
auth: {
38+
type: "some_cool_auth_plugin",
39+
secret_env_key: "SUPER_COOL_SECRET",
40+
header: "Authorization"
41+
}
42+
}
43+
end
44+
45+
before do
46+
FileUtils.mkdir_p(custom_auth_plugin_dir)
47+
File.write(File.join(custom_auth_plugin_dir, "some_cool_auth_plugin.rb"), plugin_file_content)
48+
ENV["SUPER_COOL_SECRET"] = "test-secret"
49+
end
50+
51+
after do
52+
FileUtils.rm_rf(custom_auth_plugin_dir) if Dir.exist?(custom_auth_plugin_dir)
53+
ENV.delete("SUPER_COOL_SECRET")
54+
end
55+
56+
it "successfully validates using a custom auth plugin" do
57+
# Create a test API class using the same pattern as the real API
58+
test_api_class = Class.new do
59+
include Hooks::App::Helpers
60+
include Hooks::App::Auth
61+
62+
def error!(message, code)
63+
raise StandardError, "#{message} (#{code})"
64+
end
65+
end
66+
67+
instance = test_api_class.new
68+
payload = '{"test": "data"}'
69+
headers = { "authorization" => "Bearer test-secret" }
70+
71+
# This should not raise any error
72+
expect do
73+
instance.validate_auth!(payload, headers, endpoint_config, global_config)
74+
end.not_to raise_error
75+
end
76+
77+
it "rejects requests with invalid credentials using custom auth plugin" do
78+
test_api_class = Class.new do
79+
include Hooks::App::Helpers
80+
include Hooks::App::Auth
81+
82+
def error!(message, code)
83+
raise StandardError, "#{message} (#{code})"
84+
end
85+
end
86+
87+
instance = test_api_class.new
88+
payload = '{"test": "data"}'
89+
headers = { "authorization" => "Bearer wrong-secret" }
90+
91+
# This should raise authentication failed error
92+
expect do
93+
instance.validate_auth!(payload, headers, endpoint_config, global_config)
94+
end.to raise_error(StandardError, /authentication failed/)
95+
end
96+
97+
it "works with the new configuration format" do
98+
# Test the new auth_plugin_dir configuration
99+
config = Hooks::Core::ConfigLoader.load(config_path: {
100+
auth_plugin_dir: "./custom/auth/plugins",
101+
handler_plugin_dir: "./custom/handlers"
102+
})
103+
104+
expect(config[:auth_plugin_dir]).to eq("./custom/auth/plugins")
105+
expect(config[:handler_plugin_dir]).to eq("./custom/handlers")
106+
expect(config[:handler_dir]).to eq("./custom/handlers") # backward compatibility
107+
end
108+
109+
it "maintains backward compatibility with handler_dir" do
110+
# Test that old handler_dir configuration still works
111+
config = Hooks::Core::ConfigLoader.load(config_path: {
112+
handler_dir: "./legacy/handlers"
113+
})
114+
115+
expect(config[:handler_dir]).to eq("./legacy/handlers")
116+
expect(config[:handler_plugin_dir]).to eq("./legacy/handlers")
117+
end
118+
end

0 commit comments

Comments
 (0)