diff --git a/README.md b/README.md
index 4ffd881..4f2a29c 100644
--- a/README.md
+++ b/README.md
@@ -406,10 +406,46 @@ exception is raised up the middleware chain. However, it's likely you would
prefer to show an error page than have an unhandled exception.
You can write your own middleware that catches `Rails::Auth::NotAuthorizedError`
-if you'd like. However, a default one is provided which renders a 403 response
-with a static page body if you find that helpful.
+if you'd like. However, this library includes two middleware for rescuing this
+exception for you and displaying an error page.
-To use it, add `Rails::Auth::ErrorPage::Middleware` to your app:
+#### Rails::Auth::ErrorPage::DebugMiddleware
+
+This middleware displays a detailed error page intended to help debug authorization errors:
+
+
+
+Please be aware this middleware leaks information about your ACL to a potential attacker.
+Make sure you're ok with that information being public before using it. If you would like
+to avoid leaking that information, see `Rails::Auth::ErrorPage::Middleware` below.
+
+```ruby
+app = MyRackApp.new
+
+acl = Rails::Auth::ACL.from_yaml(
+ File.read("/path/to/my/acl.yaml")
+ matchers: { allow_x509_subject: Rails::Auth::X509::Matcher }
+)
+
+acl_auth = Rails::Auth::ACL::Middleware.new(app, acl: acl)
+
+x509_auth = Rails::Auth::X509::Middleware.new(
+ acl_auth,
+ ca_file: "/path/to/my/cabundle.pem"
+ cert_filters: { 'X-SSL-Client-Cert' => :pem },
+ require_cert: true
+)
+
+error_page = Rails::Auth::ErrorPage::Middleware.new(x509_auth, acl: acl)
+
+run error_page
+```
+
+#### Rails::Auth::ErrorPage::Middleware
+
+This middleware catches `Rails::Auth::NotAuthorizedError` and renders a given static HTML file,
+e.g. the 403.html file which ships with Rails. It will not give detailed errors to your users,
+but it also won't leak information to an attacker.
```ruby
app = MyRackApp.new
diff --git a/images/debug_error_page.png b/images/debug_error_page.png
new file mode 100644
index 0000000..0cf9ea1
Binary files /dev/null and b/images/debug_error_page.png differ
diff --git a/lib/rails/auth/error_page/debug_middleware.rb b/lib/rails/auth/error_page/debug_middleware.rb
new file mode 100644
index 0000000..69ff806
--- /dev/null
+++ b/lib/rails/auth/error_page/debug_middleware.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require "erb"
+require "cgi"
+
+module Rails
+ module Auth
+ module ErrorPage
+ # Render a descriptive access denied page with debugging information about why the given
+ # request was not authorized. Useful for debugging, but leaks information about your ACL
+ # to a potential attacker. Make sure you're ok with that information being public.
+ class DebugMiddleware
+ # Configure CSP to disable JavaScript, but allow inline CSS
+ # This is just in case someone pulls off reflective XSS, but hopefully all values are
+ # properly escaped on the page so that won't happen.
+ RESPONSE_HEADERS = {
+ "Content-Security-Policy" =>
+ "default-src 'self'; " \
+ "script-src 'none'; " \
+ "style-src 'unsafe-inline'"
+ }.freeze
+
+ def initialize(app, acl: nil)
+ raise ArgumentError, "ACL must be a Rails::Auth::ACL" unless acl.is_a?(Rails::Auth::ACL)
+
+ @app = app
+ @acl = acl
+ @erb = ERB.new(File.read(File.expand_path("../debug_page.html.erb", __FILE__))).freeze
+ end
+
+ def call(env)
+ @app.call(env)
+ rescue Rails::Auth::NotAuthorizedError
+ [403, RESPONSE_HEADERS.dup, [error_page(env)]]
+ end
+
+ def error_page(env)
+ credentials = Rails::Auth.credentials(env)
+ resources = @acl.matching_resources(env)
+
+ @erb.result(binding)
+ end
+
+ def h(text)
+ CGI.escapeHTML(text || "")
+ end
+
+ def format_attributes(value)
+ value.respond_to?(:attributes) ? value.attributes.inspect : value.inspect
+ end
+
+ def format_path(path)
+ path.source.sub(/\A\\A/, "").sub(/\\z\z/, "")
+ end
+ end
+ end
+ end
+end
diff --git a/lib/rails/auth/error_page/debug_page.html.erb b/lib/rails/auth/error_page/debug_page.html.erb
new file mode 100644
index 0000000..9609713
--- /dev/null
+++ b/lib/rails/auth/error_page/debug_page.html.erb
@@ -0,0 +1,128 @@
+
+
+
+
+
+
Error: Access to the requested resource is not allowed with your current credentials.
+
Below is information about the request you made and the credentials you sent:
+
+
+
+
Request:
+
+
+
+ Method |
+ <%= h(env["REQUEST_METHOD"]) %> |
+
+
+ Path |
+ <%= h(env["REQUEST_PATH"]) %> |
+
+
+ Host |
+ <%= h(env["HTTP_HOST"]) %> |
+
+
+
+
+
+
Credentials:
+
+ <% if credentials.empty? %>
+
No credentials provided! This is a likely cause of this error.
+
Please retry the request with proper credentials.
+ <% else %>
+
+ <% credentials.each do |name, credential| %>
+
+ <%= h(name) %> |
+ <%= h(format_attributes(credential)) %> |
+
+ <% end %>
+
+ <% end %>
+
+
+
+
Authorized ACL Entries:
+ <% if resources.empty? %>
+
Error: No matching resources! This is a likely cause of this error.
+
Please check your ACL and make sure there's an entry for this route.
+ <% else %>
+
The following entries in your ACL are authorized to view this paritcular route:
+
+
+ <% resources.each do |resource| %>
+
+ <%= h((resource.http_methods || "ALL").join(" ")) %> <%= h(format_path(resource.path)) %> |
+
+
+ <% resource.predicates.each do |name, predicate| %>
+ - <%= h(name) %>: <%= h(format_attributes(predicate)) %>
+ <% end %>
+
+ |
+
+ <% end %>
+
+ <% end %>
+
+
+
+
diff --git a/lib/rails/auth/rack.rb b/lib/rails/auth/rack.rb
index 64249e9..918da23 100644
--- a/lib/rails/auth/rack.rb
+++ b/lib/rails/auth/rack.rb
@@ -14,6 +14,7 @@
require "rails/auth/acl/resource"
require "rails/auth/error_page/middleware"
+require "rails/auth/error_page/debug_middleware"
require "rails/auth/x509/certificate"
require "rails/auth/x509/filter/pem"
diff --git a/spec/rails/auth/error_page/debug_middleware_spec.rb b/spec/rails/auth/error_page/debug_middleware_spec.rb
new file mode 100644
index 0000000..b373d69
--- /dev/null
+++ b/spec/rails/auth/error_page/debug_middleware_spec.rb
@@ -0,0 +1,37 @@
+RSpec.describe Rails::Auth::ErrorPage::DebugMiddleware do
+ let(:request) { Rack::MockRequest.env_for("https://www.example.com") }
+
+ let(:example_config) { fixture_path("example_acl.yml").read }
+
+ let(:example_acl) do
+ Rails::Auth::ACL.from_yaml(
+ example_config,
+ matchers: {
+ allow_x509_subject: Rails::Auth::X509::Matcher,
+ allow_claims: ClaimsMatcher
+ }
+ )
+ end
+
+ subject(:middleware) { described_class.new(app, acl: example_acl) }
+
+ context "access granted" do
+ let(:code) { 200 }
+ let(:app) { ->(env) { [code, env, "Hello, world!"] } }
+
+ it "renders the expected response" do
+ response = middleware.call(request)
+ expect(response.first).to eq code
+ end
+ end
+
+ context "access denied" do
+ let(:app) { ->(_env) { raise(Rails::Auth::NotAuthorizedError, "not authorized!") } }
+
+ it "renders the error page" do
+ code, _env, body = middleware.call(request)
+ expect(code).to eq 403
+ expect(body.join).to include("Access Denied")
+ end
+ end
+end