├── .rspec ├── images └── debug_error_page.png ├── .gitignore ├── CONDUCT.md ├── spec ├── rails │ ├── auth_spec.rb │ └── auth │ │ ├── controller_methods_spec.rb │ │ ├── x509 │ │ ├── matcher_spec.rb │ │ ├── subject_alt_name_extension_spec.rb │ │ ├── middleware_spec.rb │ │ └── certificate_spec.rb │ │ ├── acl │ │ ├── matchers │ │ │ └── allow_all_spec.rb │ │ ├── middleware_spec.rb │ │ └── resource_spec.rb │ │ ├── credentials │ │ └── injector_middleware_spec.rb │ │ ├── acl_spec.rb │ │ ├── error_page │ │ ├── debug_middleware_spec.rb │ │ └── middleware_spec.rb │ │ ├── rspec │ │ ├── matchers │ │ │ └── acl_matchers_spec.rb │ │ └── helper_methods_spec.rb │ │ ├── monitor │ │ └── middleware_spec.rb │ │ ├── env_spec.rb │ │ └── credentials_spec.rb ├── support │ ├── claims_matcher.rb │ └── create_certs.rb ├── fixtures │ └── example_acl.yml └── spec_helper.rb ├── lib └── rails │ ├── auth │ ├── version.rb │ ├── rspec.rb │ ├── installed_constraint.rb │ ├── exceptions.rb │ ├── rspec │ │ ├── matchers │ │ │ └── acl_matchers.rb │ │ └── helper_methods.rb │ ├── x509 │ │ ├── filter │ │ │ ├── java.rb │ │ │ ├── pem_urlencoded.rb │ │ │ └── pem.rb │ │ ├── matcher.rb │ │ ├── subject_alt_name_extension.rb │ │ ├── middleware.rb │ │ └── certificate.rb │ ├── acl │ │ ├── matchers │ │ │ └── allow_all.rb │ │ ├── middleware.rb │ │ └── resource.rb │ ├── controller_methods.rb │ ├── monitor │ │ └── middleware.rb │ ├── credentials │ │ └── injector_middleware.rb │ ├── rack.rb │ ├── credentials.rb │ ├── error_page │ │ ├── middleware.rb │ │ ├── debug_middleware.rb │ │ └── debug_page.html.erb │ ├── helpers.rb │ ├── env.rb │ ├── config_builder.rb │ └── acl.rb │ └── auth.rb ├── Guardfile ├── Rakefile ├── Gemfile ├── BUG-BOUNTY.md ├── .rubocop.yml ├── .github └── workflows │ ├── mri.yml │ └── jruby.yml ├── CONTRIBUTING.md ├── docs ├── Monitor.md ├── Home.md ├── Matchers.md ├── Error-Handling.md ├── Design-Overview.md ├── Access-Control-Lists.md ├── Comparison-With-Other-Libraries.md ├── X.509.md ├── RSpec-Support.md ├── Rails-Usage.md └── Rack-Usage.md ├── rails-auth.gemspec ├── README.md ├── CHANGES.md └── LICENSE /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --order random 4 | --require spec_helper 5 | -------------------------------------------------------------------------------- /images/debug_error_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/rails-auth/HEAD/images/debug_error_page.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | This project follows the Square Open Source Code of Conduct: 4 | 5 | https://corner.squareup.com/codeofconduct.html 6 | -------------------------------------------------------------------------------- /spec/rails/auth_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rails::Auth do 4 | it "has a version number" do 5 | expect(Rails::Auth::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/rails/auth/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | # Pluggable authentication and authorization for Rack/Rails 5 | module Auth 6 | VERSION = "3.2.0" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | guard :rspec, cmd: "bundle exec rspec" do 4 | watch(%r{^spec/.+_spec\.rb$}) 5 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 6 | watch("spec/spec_helper.rb") { "spec" } 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | require "rubocop/rake_task" 6 | 7 | RSpec::Core::RakeTask.new(:spec) 8 | RuboCop::RakeTask.new 9 | 10 | task default: %w[spec rubocop] 11 | -------------------------------------------------------------------------------- /lib/rails/auth/rspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/auth/rspec/helper_methods" 4 | require "rails/auth/rspec/matchers/acl_matchers" 5 | 6 | RSpec.configure do |config| 7 | config.include Rails::Auth::RSpec::HelperMethods, acl_spec: true 8 | end 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activesupport" 6 | gem "certificate_authority", require: false 7 | gem "guard-rspec" 8 | gem "pry-byebug", platform: :mri 9 | gem "rake" 10 | gem "rspec" 11 | gem "rubocop" 12 | 13 | gemspec 14 | -------------------------------------------------------------------------------- /spec/support/claims_matcher.rb: -------------------------------------------------------------------------------- 1 | # A strawman matcher for claims-based credentials for use in tests 2 | # frozen_string_literal: true 3 | 4 | class ClaimsMatcher 5 | def initialize(options) 6 | @options = options 7 | end 8 | 9 | def match(_env) 10 | # Pretend like we have a claim to be in the "example" group 11 | @options["group"] == "example" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /BUG-BOUNTY.md: -------------------------------------------------------------------------------- 1 | Serious about security 2 | ====================== 3 | 4 | Square recognizes the important contributions the security research community 5 | can make. We therefore encourage reporting security issues with the code 6 | contained in this repository. 7 | 8 | If you believe you have discovered a security vulnerability, please follow the 9 | guidelines at . 10 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | DisplayCopNames: true 4 | TargetRubyVersion: 2.5 5 | 6 | Style/StringLiterals: 7 | EnforcedStyle: double_quotes 8 | 9 | Layout/HashAlignment: 10 | Enabled: false 11 | 12 | Metrics: 13 | Enabled: false 14 | 15 | Naming/MethodParameterName: 16 | MinNameLength: 2 17 | 18 | Style/ModuleFunction: 19 | Enabled: false 20 | 21 | Style/SafeNavigation: 22 | Enabled: false 23 | -------------------------------------------------------------------------------- /lib/rails/auth/installed_constraint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | # Rails constraint to make sure the ACLs have been installed 6 | class InstalledConstraint 7 | def initialize(config = Rails.application) 8 | @config = config 9 | end 10 | 11 | def matches?(_request) 12 | @config.middleware.include?(Rails::Auth::ACL::Middleware) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/rails/auth.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support" 4 | require "active_support/core_ext/object" 5 | 6 | # Pull in core library components that work with any Rack application 7 | require "rails/auth/rack" 8 | 9 | # Rails configuration builder 10 | require "rails/auth/config_builder" 11 | 12 | # Rails controller method support 13 | require "rails/auth/controller_methods" 14 | 15 | # Rails router constraint 16 | require "rails/auth/installed_constraint" 17 | -------------------------------------------------------------------------------- /lib/rails/auth/exceptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | # Base class of all Rails::Auth errors 6 | Error = Class.new(StandardError) 7 | 8 | # Unauthorized! 9 | NotAuthorizedError = Class.new(Error) 10 | 11 | # Error parsing e.g. an ACL 12 | ParseError = Class.new(Error) 13 | 14 | # Internal errors involving authorizing things that are already authorized 15 | AlreadyAuthorizedError = Class.new(Error) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/rails/auth/rspec/matchers/acl_matchers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define(:permit) do |env| 4 | description do 5 | method = env["REQUEST_METHOD"] 6 | credentials = Rails::Auth.credentials(env) 7 | message = "allow #{method}s by " 8 | 9 | return "#{message}unauthenticated clients" if credentials.count.zero? 10 | 11 | message + credentials.values.map(&:inspect).join(", ") 12 | end 13 | 14 | match { |acl| acl.match(env) } 15 | end 16 | -------------------------------------------------------------------------------- /lib/rails/auth/x509/filter/java.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | module X509 6 | module Filter 7 | # Extract OpenSSL::X509::Certificates from java.security.cert.Certificate 8 | class Java 9 | def call(certs) 10 | return if certs.nil? || certs.empty? 11 | 12 | OpenSSL::X509::Certificate.new(certs[0].get_encoded).freeze 13 | end 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/rails/auth/x509/filter/pem_urlencoded.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | module X509 6 | module Filter 7 | # Extract OpenSSL::X509::Certificates from Privacy Enhanced Mail (PEM) certificates 8 | # that are URL encoded ($ssl_client_escaped_cert from Nginx). 9 | class PemUrlencoded < Pem 10 | def call(encoded_pem) 11 | super(URI.decode_www_form_component(encoded_pem)) 12 | end 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/fixtures/example_acl.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - resources: 3 | - method: ALL 4 | path: /foo/bar/.* 5 | allow_x509_subject: 6 | ou: ponycopter 7 | allow_claims: 8 | group: example 9 | - resources: 10 | - method: GET 11 | path: /baz/.* 12 | allow_x509_subject: 13 | ou: ponycopter 14 | - resources: 15 | - method: ALL 16 | path: /_admin/?.* 17 | allow_claims: 18 | group: admins 19 | - resources: 20 | - method: GET 21 | path: /internal/frobnobs/.* 22 | allow_x509_subject: 23 | ou: frobnobber 24 | - resources: 25 | - method: GET 26 | path: / 27 | allow_all: true 28 | -------------------------------------------------------------------------------- /.github/workflows/mri.yml: -------------------------------------------------------------------------------- 1 | name: CI - MRI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | ruby-version: 15 | - 3.1 16 | - 3.2 17 | - 3.3 18 | - 3.4 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | bundler-cache: true 26 | ruby-version: ${{ matrix.ruby-version }} 27 | - name: Run tests 28 | run: bundle exec rake 29 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | If you would like to contribute code to *rails-auth* you can do so through GitHub by 4 | forking the repository and sending a pull request. 5 | 6 | When submitting code, please make every effort to follow existing conventions 7 | and style in order to keep the code as readable as possible. Please also make 8 | sure all tests pass by running `bundle exec rspec spec`, and format your code 9 | according to `rubocop` rules. 10 | 11 | Before your code can be accepted into the project you must also sign the 12 | Individual Contributor License Agreement. We use [cla-assistant.io][1] and you 13 | will be prompted to sign once a pull request is opened. 14 | 15 | [1]: https://cla-assistant.io/ 16 | -------------------------------------------------------------------------------- /.github/workflows/jruby.yml: -------------------------------------------------------------------------------- 1 | name: CI - JRuby 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | ruby-version: 15 | - jruby-9.3 16 | - jruby-9.4 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Java 21 | uses: actions/setup-java@v2 22 | with: 23 | distribution: temurin 24 | java-version: 8 25 | - name: Set up Ruby 26 | uses: ruby/setup-ruby@v1 27 | with: 28 | bundler-cache: true 29 | ruby-version: jruby 30 | - name: Run tests 31 | run: bundle exec rake 32 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | require "rails/auth" 5 | require "rails/auth/rspec" 6 | require "support/create_certs" 7 | require "support/claims_matcher" 8 | require "pathname" 9 | 10 | RSpec.configure(&:disable_monkey_patching!) 11 | 12 | def cert_path(name) 13 | Pathname.new(File.expand_path("../tmp/certs", __dir__)).join(name) 14 | end 15 | 16 | def fixture_path(*args) 17 | Pathname.new(File.expand_path("fixtures", __dir__)).join(*args) 18 | end 19 | 20 | def env_for(method, path, host = "127.0.0.1") 21 | { 22 | "REQUEST_METHOD" => method.to_s.upcase, 23 | "PATH_INFO" => path, 24 | "HTTP_HOST" => host 25 | } 26 | end 27 | -------------------------------------------------------------------------------- /docs/Monitor.md: -------------------------------------------------------------------------------- 1 | The `Rails::Auth::Monitor::Middleware` invokes a user-specified callback each time an AuthZ decision is made. The callback should look like this: 2 | 3 | ```ruby 4 | my_monitor_callback = lambda do |env, success| 5 | [...] 6 | end 7 | ``` 8 | 9 | The parameters are: 10 | 11 | * **env:** the full Rack environment associated with the request 12 | * **success:** whether or not the request was authorized 13 | 14 | On Rails, you can pass this callback as the `monitor:` option to `Rails::Auth::ConfigBuilder.production`. See [[Rails Usage]] for more information. 15 | 16 | On Rack, you will have to instantiate the middleware yourself. See [[Rack Usage]] for more information. 17 | 18 | These callbacks are useful for logging authorization decisions and/or reporting authorization failures to e.g. a central monitoring/alerting system. -------------------------------------------------------------------------------- /lib/rails/auth/acl/matchers/allow_all.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | class ACL 6 | # Built-in matchers 7 | module Matchers 8 | # Allows unauthenticated clients to access to a given resource 9 | class AllowAll 10 | def initialize(enabled) 11 | raise ArgumentError, "enabled must be true/false" unless [true, false].include?(enabled) 12 | 13 | @enabled = enabled 14 | end 15 | 16 | def match(_env) 17 | @enabled 18 | end 19 | 20 | # Generates inspectable attributes for debugging 21 | # 22 | # @return [true, false] is the matcher enabled? 23 | def attributes 24 | @enabled 25 | end 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/rails/auth/controller_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/hash_with_indifferent_access" 4 | 5 | # rubocop:disable Naming/MemoizedInstanceVariableName 6 | module Rails 7 | module Auth 8 | # Convenience methods designed to be included in an ActionController::Base subclass 9 | # Recommended use: include in ApplicationController 10 | module ControllerMethods 11 | # Obtain credentials for the current request 12 | # 13 | # @return [HashWithIndifferentAccess] credentials extracted from the environment 14 | # 15 | def credentials 16 | @_rails_auth_credentials ||= begin 17 | creds = Rails::Auth.credentials(request.env) 18 | HashWithIndifferentAccess.new(creds).freeze 19 | end 20 | end 21 | end 22 | end 23 | end 24 | # rubocop:enable Naming/MemoizedInstanceVariableName 25 | -------------------------------------------------------------------------------- /docs/Home.md: -------------------------------------------------------------------------------- 1 | ## Intro 2 | 3 | * [[Design Overview]]: an overview of Rails::Auth's middleware-based design 4 | * [[Comparison With Other Libraries]]: How Rails::Auth compares to other Rails/Rack auth libraries/frameworks 5 | 6 | ## Usage 7 | 8 | * [[Rails Usage]]: how to add Rails::Auth to a Rails app 9 | * [[Rack Usage]]: how to use Rails::Auth's middleware outside of a Rails app 10 | * [[Access Control Lists]]: how to define policy for what actions are allowed 11 | * [[Matchers]]: how to make access control decisions based on credentials 12 | * [[X.509]]: how to authorize requests using X.509 client certificates 13 | * [[Error Handling]]: show a rich debugger or static 403 page on authorization errors 14 | * [[Monitor]]: invokes a user-specified callback each time an AuthZ decision is made 15 | * [[RSpec Support]]: use RSpec to write integration tests for Rails::Auth features and specs for ACLs -------------------------------------------------------------------------------- /lib/rails/auth/monitor/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | module Monitor 6 | # Fires a user-specified callback which reports on authorization success 7 | # or failure. Useful for logging or monitoring systems for AuthZ failures 8 | class Middleware 9 | def initialize(app, callback) 10 | raise ArgumentError, "callback must respond to :call" unless callback.respond_to?(:call) 11 | 12 | @app = app 13 | @callback = callback 14 | end 15 | 16 | def call(env) 17 | begin 18 | result = @app.call(env) 19 | rescue Rails::Auth::NotAuthorizedError 20 | @callback.call(env, false) 21 | raise 22 | end 23 | 24 | @callback.call(env, true) 25 | result 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/rails/auth/x509/filter/pem.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | module X509 6 | module Filter 7 | # Extract OpenSSL::X509::Certificates from Privacy Enhanced Mail (PEM) certificates 8 | class Pem 9 | def call(pem) 10 | # Normalize the whitespace in the certificate to the exact format 11 | # certificates are normally formatted in otherwise parsing with fail 12 | # with a 'nested asn1 error'. split(" ") handles sequential whitespace 13 | # characters like \t, \n, and space. 14 | OpenSSL::X509::Certificate.new(pem.split.instance_eval do 15 | [[self[0], self[1]].join(" "), self[2...-2], [self[-2], self[-1]].join(" ")] 16 | .flatten.join("\n") 17 | end).freeze 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/rails/auth/credentials/injector_middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | class Credentials 6 | # A middleware for injecting an arbitrary credentials hash into the Rack environment 7 | # This is intended for development and testing purposes where you would like to 8 | # simulate a given X.509 certificate being used in a request or user logged in. 9 | # The credentials argument should either be a hash or a proc that returns one. 10 | class InjectorMiddleware 11 | def initialize(app, credentials) 12 | @app = app 13 | @credentials = credentials 14 | end 15 | 16 | def call(env) 17 | credentials = @credentials.respond_to?(:call) ? @credentials.call(env) : @credentials 18 | env[Rails::Auth::Env::CREDENTIALS_ENV_KEY] = credentials 19 | @app.call(env) 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/rails/auth/controller_methods_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rails::Auth::ControllerMethods do 4 | let(:controller_class) do 5 | Class.new do 6 | attr_reader :request 7 | 8 | def initialize(env) 9 | @request = Struct.new(:env).new(env) 10 | end 11 | 12 | include Rails::Auth::ControllerMethods 13 | end 14 | end 15 | 16 | describe "#credentials" do 17 | let(:example_credential_type) { "x509" } 18 | let(:example_credential_value) { instance_double(Rails::Auth::X509::Certificate) } 19 | 20 | let(:example_env) { Rails::Auth.add_credential({}, example_credential_type, example_credential_value) } 21 | let(:example_controller) { controller_class.new(example_env) } 22 | 23 | it "extracts credentials from the Rack environment" do 24 | expect(example_controller.credentials[example_credential_type.to_sym]).to eq example_credential_value 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/rails/auth/rack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Core library components that work with any Rack application 4 | require "rack" 5 | require "openssl" 6 | 7 | require "rails/auth/version" 8 | 9 | require "rails/auth/env" 10 | require "rails/auth/exceptions" 11 | require "rails/auth/helpers" 12 | 13 | require "rails/auth/acl" 14 | require "rails/auth/acl/middleware" 15 | require "rails/auth/acl/resource" 16 | 17 | require "rails/auth/credentials" 18 | require "rails/auth/credentials/injector_middleware" 19 | 20 | require "rails/auth/error_page/middleware" 21 | require "rails/auth/error_page/debug_middleware" 22 | 23 | require "rails/auth/monitor/middleware" 24 | 25 | require "rails/auth/x509/certificate" 26 | require "rails/auth/x509/filter/pem" 27 | require "rails/auth/x509/filter/pem_urlencoded" 28 | require "rails/auth/x509/filter/java" if defined?(JRUBY_VERSION) 29 | require "rails/auth/x509/matcher" 30 | require "rails/auth/x509/middleware" 31 | require "rails/auth/x509/subject_alt_name_extension" 32 | -------------------------------------------------------------------------------- /lib/rails/auth/x509/matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | module X509 6 | # Matcher for making assertions about X.509 certificates 7 | class Matcher 8 | # @option options [String] cn Common Name of the subject 9 | # @option options [String] ou Organizational Unit of the subject 10 | def initialize(options) 11 | @options = options.freeze 12 | end 13 | 14 | # @param [Hash] env Rack environment 15 | def match(env) 16 | certificate = Rails::Auth.credentials(env)["x509"] 17 | return false unless certificate 18 | 19 | @options.all? { |name, value| certificate[name] == value } 20 | end 21 | 22 | # Generates inspectable attributes for debugging 23 | # 24 | # @return [Hash] hash containing parts of the certificate subject to match (cn, ou) 25 | def attributes 26 | @options 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/rails/auth/x509/matcher_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rails::Auth::X509::Matcher do 4 | let(:example_cert) { OpenSSL::X509::Certificate.new(cert_path("valid.crt").read) } 5 | let(:example_certificate) { Rails::Auth::X509::Certificate.new(example_cert) } 6 | 7 | let(:example_ou) { "ponycopter" } 8 | let(:another_ou) { "somethingelse" } 9 | 10 | let(:example_env) do 11 | { Rails::Auth::Env::CREDENTIALS_ENV_KEY => { "x509" => example_certificate } } 12 | end 13 | 14 | describe "#match" do 15 | it "matches against a valid Rails::Auth::X509::Credential" do 16 | matcher = described_class.new(ou: example_ou) 17 | expect(matcher.match(example_env)).to eq true 18 | end 19 | 20 | it "doesn't match if the subject mismatches" do 21 | matcher = described_class.new(ou: another_ou) 22 | expect(matcher.match(example_env)).to eq false 23 | end 24 | end 25 | 26 | it "knows its attributes" do 27 | matcher = described_class.new(ou: example_ou) 28 | expect(matcher.attributes).to eq(ou: example_ou) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/rails/auth/acl/matchers/allow_all_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rails::Auth::ACL::Matchers::AllowAll do 4 | let(:matcher) { described_class.new(enabled) } 5 | let(:example_env) { env_for(:get, "/") } 6 | 7 | describe "#initialize" do 8 | it "raises if given nil" do 9 | expect { described_class.new(nil) }.to raise_error(ArgumentError) 10 | end 11 | 12 | it "raises if given a non-boolean" do 13 | expect { described_class.new(42) }.to raise_error(ArgumentError) 14 | end 15 | end 16 | 17 | describe "#match" do 18 | context "enabled" do 19 | let(:enabled) { true } 20 | 21 | it "allows all requests" do 22 | expect(matcher.match(example_env)).to eq true 23 | end 24 | end 25 | 26 | context "disabled" do 27 | let(:enabled) { false } 28 | 29 | it "rejects all requests" do 30 | expect(matcher.match(example_env)).to eq false 31 | end 32 | end 33 | end 34 | 35 | it "knows its attributes" do 36 | matcher = described_class.new(true) 37 | expect(matcher.attributes).to eq true 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/rails/auth/credentials/injector_middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rails::Auth::Credentials::InjectorMiddleware do 4 | let(:request) { Rack::MockRequest.env_for("https://www.example.com") } 5 | let(:app) { ->(env) { [200, env, "Hello, world!"] } } 6 | let(:middleware) { described_class.new(app, credentials) } 7 | let(:credentials) { { "foo" => "bar" } } 8 | 9 | it "overrides rails-auth credentials in the rack environment" do 10 | _response, env = middleware.call(request) 11 | expect(env[Rails::Auth::Env::CREDENTIALS_ENV_KEY]).to eq credentials 12 | end 13 | 14 | context "with a proc for credentials" do 15 | let(:credentials_proc) { instance_double(Proc) } 16 | let(:middleware) { described_class.new(app, credentials_proc) } 17 | 18 | it "overrides rails-auth credentials in the rack environment" do 19 | expect(credentials_proc).to receive(:call).with(request).and_return(credentials) 20 | 21 | _response, env = middleware.call(request) 22 | 23 | expect(env[Rails::Auth::Env::CREDENTIALS_ENV_KEY]).to eq credentials 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/rails/auth/x509/subject_alt_name_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | module X509 6 | # Provides convenience methods for subjectAltName extension of X.509 certificates 7 | class SubjectAltNameExtension 8 | attr_reader :dns_names, :ips, :uris 9 | 10 | DNS_REGEX = /^DNS:/i.freeze 11 | IP_REGEX = /^IP( Address)?:/i.freeze 12 | URI_REGEX = /^URI:/i.freeze 13 | 14 | def initialize(certificate) 15 | unless certificate.is_a?(OpenSSL::X509::Certificate) 16 | raise TypeError, "expecting OpenSSL::X509::Certificate, got #{certificate.class}" 17 | end 18 | 19 | extension = certificate.extensions.detect { |ext| ext.oid == "subjectAltName" } 20 | values = (extension&.value&.split(",") || []).map(&:strip) 21 | 22 | @dns_names = values.grep(DNS_REGEX) { |v| v.sub(DNS_REGEX, "") }.freeze 23 | @ips = values.grep(IP_REGEX) { |v| v.sub(IP_REGEX, "") }.freeze 24 | @uris = values.grep(URI_REGEX) { |v| v.sub(URI_REGEX, "") }.freeze 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /docs/Matchers.md: -------------------------------------------------------------------------------- 1 | Matchers are the component of Rails::Auth that make authorization decisions based on credentials. The following matchers are built-in and always available: 2 | 3 | The following [[matchers]] are built-in and always available: 4 | 5 | * **allow_all**: (options: `true` or `false`) always allow requests to the 6 | given resources (so long as `true` is passed as the option) 7 | 8 | The `Rails::Auth::X509::Matcher` class can be used to make authorization decisions based on X.509 certificates. For more information, see the [[X.509]] Wiki page. 9 | 10 | Custom [[matchers]] can be any Ruby class that responds to the `#match` method. The full Rack environment is passed to `#match`. The corresponding object from the ACL definition is passed to the class's `#initialize` method. 11 | 12 | Here is an example of a simple custom matcher: 13 | 14 | ```ruby 15 | class MyClaimsMatcher 16 | def initialize(options) 17 | @options = options 18 | end 19 | 20 | def match(env) 21 | claims = Rails::Auth.credentials(env)["claims"] 22 | return false unless credential 23 | 24 | @options["groups"].any? { |group| claims["groups"].include?(group) } 25 | end 26 | end 27 | 28 | ``` -------------------------------------------------------------------------------- /docs/Error-Handling.md: -------------------------------------------------------------------------------- 1 | Rails::Auth includes two different middlewares for rendering error responses: one for debugging, and one intended for production environments which renders a static 403 page (or corresponding JSON response). 2 | 3 | ## Debug Page 4 | 5 | The `Rails::Auth::ErrorPage::DebugMiddleware` provides a rich inspector for why access was denied: 6 | 7 | ![Debug Page](https://github.com/square/rails-auth/blob/master/images/debug_error_page.png?raw=true) 8 | 9 | This page is enabled automatically in the development environment for Rails apps. It can also be enabled in production by passing the `error_page: :debug` option to `Rails::Auth::ConfigBuilder.production`. See [[Rails Usage]] for more information. 10 | 11 | ## Static Page 12 | 13 | The `Rails::Auth::ErrorPage::Middleware` renders a static HTML page and/or JSON response in the event of an authorization failure. 14 | 15 | This middleware is used by default in the production environment, and defaults to rendering `public/403.html`. The location of the page can be overridden using the `error_page:` option and passing a `Pathname` or `String` to the file's location. ERB is not presently supported. See [[Rails Usage]] for more information. 16 | -------------------------------------------------------------------------------- /lib/rails/auth/credentials.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | 5 | module Rails 6 | # Modular resource-based authentication and authorization for Rails/Rack 7 | module Auth 8 | # Stores a set of credentials 9 | class Credentials 10 | extend Forwardable 11 | include Enumerable 12 | 13 | def_delegators :@credentials, :fetch, :empty?, :key?, :each, :to_hash, :values 14 | 15 | def self.from_rack_env(env) 16 | new(env.fetch(Rails::Auth::Env::CREDENTIALS_ENV_KEY, {})) 17 | end 18 | 19 | def initialize(credentials = {}) 20 | raise TypeError, "expected Hash, got #{credentials.class}" unless credentials.is_a?(Hash) 21 | 22 | @credentials = credentials 23 | end 24 | 25 | def []=(type, value) 26 | return if @credentials.key?(type) && @credentials[type] == value 27 | raise TypeError, "expected String for type, got #{type.class}" unless type.is_a?(String) 28 | raise AlreadyAuthorizedError, "credential '#{type}' has already been set" if @credentials.key?(type) 29 | 30 | @credentials[type] = value 31 | end 32 | 33 | def [](type) 34 | @credentials[type.to_s] 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/rails/auth/acl_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rails::Auth::ACL do 4 | let(:example_config) { fixture_path("example_acl.yml").read } 5 | 6 | let(:example_acl) do 7 | described_class.from_yaml( 8 | example_config, 9 | matchers: { 10 | allow_x509_subject: Rails::Auth::X509::Matcher, 11 | allow_claims: ClaimsMatcher 12 | } 13 | ) 14 | end 15 | 16 | describe "#initialize" do 17 | it "raises TypeError if given a non-Array ACL type" do 18 | expect { described_class.new(:bogus) }.to raise_error(TypeError) 19 | end 20 | end 21 | 22 | describe "#match" do 23 | it "matches routes against the ACL" do 24 | expect(example_acl.match(env_for(:get, "/"))).to eq "allow_all" 25 | expect(example_acl.match(env_for(:get, "/foo/bar/baz"))).to eq "allow_claims" 26 | expect(example_acl.match(env_for(:get, "/_admin"))).to eq nil 27 | end 28 | end 29 | 30 | describe "#matching_resources" do 31 | it "finds Rails::Auth::ACL::Resource objects that match the request" do 32 | resources = example_acl.matching_resources(env_for(:get, "/foo/bar/baz")) 33 | expect(resources.first.path).to eq %r{\A/foo/bar/.*\z} 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/rails/auth/error_page/debug_middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rails::Auth::ErrorPage::DebugMiddleware do 4 | let(:request) { Rack::MockRequest.env_for("https://www.example.com") } 5 | 6 | let(:example_config) { fixture_path("example_acl.yml").read } 7 | 8 | let(:example_acl) do 9 | Rails::Auth::ACL.from_yaml( 10 | example_config, 11 | matchers: { 12 | allow_x509_subject: Rails::Auth::X509::Matcher, 13 | allow_claims: ClaimsMatcher 14 | } 15 | ) 16 | end 17 | 18 | subject(:middleware) { described_class.new(app, acl: example_acl) } 19 | 20 | context "access granted" do 21 | let(:code) { 200 } 22 | let(:app) { ->(env) { [code, env, "Hello, world!"] } } 23 | 24 | it "renders the expected response" do 25 | response = middleware.call(request) 26 | expect(response.first).to eq code 27 | end 28 | end 29 | 30 | context "access denied" do 31 | let(:app) { ->(_env) { raise(Rails::Auth::NotAuthorizedError, "not authorized!") } } 32 | 33 | it "renders the error page" do 34 | code, _env, body = middleware.call(request) 35 | expect(code).to eq 403 36 | expect(body.join).to include("Access Denied") 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/rails/auth/acl/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | class ACL 6 | # Authorizes requests by matching them against the given ACL 7 | class Middleware 8 | # Create Rails::Auth::ACL::Middleware from the args you'd pass to Rails::Auth::ACL's constructor 9 | def self.from_acl_config(app, **args) 10 | new(app, acl: Rails::Auth::ACL.new(**args)) 11 | end 12 | 13 | # Create a new ACL Middleware object 14 | # 15 | # @param [Object] app next app in the Rack middleware chain 16 | # @param [Hash] acl Rails::Auth::ACL object to authorize the request with 17 | # 18 | # @return [Rails::Auth::ACL::Middleware] new ACL middleware instance 19 | def initialize(app, acl: nil) 20 | raise ArgumentError, "no acl given" unless acl 21 | 22 | @app = app 23 | @acl = acl 24 | end 25 | 26 | def call(env) 27 | unless Rails::Auth.authorized?(env) 28 | matcher_name = @acl.match(env) 29 | raise NotAuthorizedError, "unauthorized request" unless matcher_name 30 | 31 | Rails::Auth.set_allowed_by(env, "matcher:#{matcher_name}") 32 | end 33 | 34 | @app.call(env) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/rails/auth/rspec/matchers/acl_matchers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "RSpec ACL matchers", acl_spec: true do 4 | let(:another_certificate) { x509_certificate_hash(ou: "derpderp") } 5 | let(:example_certificate) { x509_certificate_hash(ou: "ponycopter") } 6 | 7 | subject do 8 | Rails::Auth::ACL.from_yaml( 9 | fixture_path("example_acl.yml").read, 10 | matchers: { 11 | allow_x509_subject: Rails::Auth::X509::Matcher, 12 | allow_claims: ClaimsMatcher 13 | } 14 | ) 15 | end 16 | 17 | describe "/baz/quux" do 18 | it { is_expected.to permit get_request(credentials: example_certificate) } 19 | it { is_expected.not_to permit get_request(credentials: another_certificate) } 20 | it { is_expected.not_to permit get_request } 21 | 22 | it "has the correct description" do 23 | expect(permit(get_request(credentials: example_certificate)).description) 24 | .to eq('allow GETs by #') 25 | expect(permit(get_request(credentials: another_certificate)).description) 26 | .to eq('allow GETs by #') 27 | expect(permit(get_request).description) 28 | .to eq("allow GETs by unauthenticated clients") 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /rails-auth.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "rails/auth/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "rails-auth" 9 | spec.version = Rails::Auth::VERSION 10 | spec.authors = ["Tony Arcieri"] 11 | spec.email = ["tonyarcieri@squareup.com"] 12 | spec.homepage = "https://github.com/square/rails-auth/" 13 | spec.licenses = ["Apache-2.0"] 14 | 15 | spec.summary = "Modular resource-oriented authentication and authorization for Rails/Rack" 16 | spec.description = <<-DESCRIPTION.strip.gsub(/\s+/, " ") 17 | A plugin-based framework for supporting multiple authentication and 18 | authorization systems in Rails/Rack apps. Supports resource-oriented 19 | route-by-route access control lists with TLS authentication. 20 | DESCRIPTION 21 | 22 | # Only allow gem to be pushed to https://rubygems.org 23 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 24 | spec.metadata["rubygems_mfa_required"] = "true" 25 | 26 | spec.files = `git ls-files`.split("\n") 27 | spec.bindir = "exe" 28 | spec.require_paths = ["lib"] 29 | 30 | spec.required_ruby_version = ">= 2.5.0" 31 | 32 | spec.add_dependency "activesupport" 33 | spec.add_dependency "rack" 34 | end 35 | -------------------------------------------------------------------------------- /spec/rails/auth/monitor/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rails::Auth::Monitor::Middleware do 4 | let(:request) { Rack::MockRequest.env_for("https://www.example.com") } 5 | 6 | describe "access granted" do 7 | let(:code) { 200 } 8 | let(:app) { ->(env) { [code, env, "Hello, world!"] } } 9 | 10 | it "fires the callback with the env and true" do 11 | callback_fired = false 12 | 13 | middleware = described_class.new(app, lambda do |env, success| 14 | callback_fired = true 15 | expect(env).to be_a Hash 16 | expect(success).to eq true 17 | end) 18 | 19 | response = middleware.call(request) 20 | expect(callback_fired).to eq true 21 | expect(response.first).to eq code 22 | end 23 | end 24 | 25 | describe "access denied" do 26 | let(:app) { ->(_env) { raise(Rails::Auth::NotAuthorizedError, "not authorized!") } } 27 | 28 | it "renders the error page" do 29 | callback_fired = false 30 | 31 | middleware = described_class.new(app, lambda do |env, success| 32 | callback_fired = true 33 | expect(env).to be_a Hash 34 | expect(success).to eq false 35 | end) 36 | 37 | expect { middleware.call(request) }.to raise_error(Rails::Auth::NotAuthorizedError) 38 | expect(callback_fired).to eq true 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/rails/auth/error_page/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | module ErrorPage 6 | # Render an error page in the event Rails::Auth::NotAuthorizedError is raised 7 | class Middleware 8 | def initialize(app, page_body: nil, json_body: { message: "Access denied" }) 9 | raise TypeError, "page_body must be a String" unless page_body.is_a?(String) 10 | 11 | @app = app 12 | @page_body = page_body.freeze 13 | @json_body = json_body.to_json 14 | end 15 | 16 | def call(env) 17 | @app.call(env) 18 | rescue Rails::Auth::NotAuthorizedError 19 | access_denied(env) 20 | end 21 | 22 | private 23 | 24 | def access_denied(env) 25 | case response_format(env) 26 | when :json 27 | [403, { "X-Powered-By" => "rails-auth", "Content-Type" => "application/json" }, [@json_body]] 28 | else 29 | [403, { "X-Powered-By" => "rails-auth", "Content-Type" => "text/html" }, [@page_body]] 30 | end 31 | end 32 | 33 | def response_format(env) 34 | accept_format = env["HTTP_ACCEPT"] 35 | return :json if accept_format && accept_format.downcase.start_with?("application/json") 36 | return :json if env["PATH_INFO"] && env["PATH_INFO"].end_with?(".json") 37 | 38 | nil 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/rails/auth/acl/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logger" 4 | 5 | RSpec.describe Rails::Auth::ACL::Middleware do 6 | let(:request) { Rack::MockRequest.env_for("https://www.example.com") } 7 | let(:app) { ->(env) { [200, env, "Hello, world!"] } } 8 | let(:acl) { instance_double(Rails::Auth::ACL, match: authorized) } 9 | let(:middleware) { described_class.new(app, acl: acl) } 10 | 11 | context "authorized" do 12 | let(:authorized) { true } 13 | 14 | it "allows authorized requests" do 15 | expect(middleware.call(request)[0]).to eq 200 16 | end 17 | end 18 | 19 | context "unauthorized" do 20 | let(:authorized) { false } 21 | 22 | it "raises Rails::Auth::NotAuthorizedError for unauthorized requests" do 23 | expect { expect(middleware.call(request)) }.to raise_error(Rails::Auth::NotAuthorizedError) 24 | end 25 | end 26 | 27 | context "externally authorized requests" do 28 | let(:authorized) { false } 29 | let(:external_middleware) do 30 | Class.new do 31 | def initialize(app) 32 | @app = app 33 | end 34 | 35 | def call(env) 36 | allowed_by = "example" 37 | Rails::Auth.authorized!(env, allowed_by) 38 | @app.call(env) 39 | end 40 | end 41 | end 42 | 43 | it "allows externally authorized requests" do 44 | expect(external_middleware.new(middleware).call(request)[0]).to eq 200 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/rails/auth/env_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rails::Auth::Env do 4 | let(:rack_env) { Rack::MockRequest.env_for("https://www.example.com") } 5 | let(:example_authority) { "some-authority" } 6 | 7 | subject(:example_env) { described_class.new(rack_env) } 8 | 9 | it "stores authorization state in the Rack environment" do 10 | expect(example_env).not_to be_authorized 11 | expect(example_env.to_rack.key?(described_class::AUTHORIZED_ENV_KEY)).to eq false 12 | expect(example_env.to_rack.key?(described_class::ALLOWED_BY_ENV_KEY)).to eq false 13 | 14 | example_env.authorize(example_authority) 15 | expect(example_env).to be_authorized 16 | expect(example_env.to_rack[described_class::AUTHORIZED_ENV_KEY]).to eq true 17 | expect(example_env.to_rack[described_class::ALLOWED_BY_ENV_KEY]).to eq example_authority 18 | end 19 | 20 | it "stores authorizers in the Rack environment" do 21 | expect(example_env.allowed_by).to be_nil 22 | expect(example_env.to_rack.key?(described_class::ALLOWED_BY_ENV_KEY)).to eq false 23 | 24 | example_env.allowed_by = example_authority 25 | expect(example_env.allowed_by).to eq example_authority 26 | expect(example_env.to_rack[described_class::ALLOWED_BY_ENV_KEY]).to eq example_authority 27 | end 28 | 29 | # TODO: this could probably be a bit more extensive 30 | it "stores credentials in the Rack enviroment" do 31 | expect(example_env.credentials).to be_a Rails::Auth::Credentials 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/rails/auth/x509/subject_alt_name_extension_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rails::Auth::X509::SubjectAltNameExtension do 4 | let(:example_cert) { OpenSSL::X509::Certificate.new(cert_path("valid.crt").read) } 5 | let(:example_cert_with_extension) { OpenSSL::X509::Certificate.new(cert_path("valid_with_ext.crt").read) } 6 | let(:extension_for_cert) { described_class.new(example_cert) } 7 | let(:extension_for_cert_with_san) { described_class.new(example_cert_with_extension) } 8 | let(:example_dns_names) { %w[example.com exemplar.com somethingelse.com] } 9 | let(:example_ips) { %w[0.0.0.0 127.0.0.1 192.168.1.1] } 10 | let(:example_uris) { %w[spiffe://example.com/exemplar https://www.example.com/page1 https://www.example.com/page2] } 11 | 12 | describe "for cert without extensions" do 13 | it "returns no DNS names" do 14 | expect(extension_for_cert.dns_names).to be_empty 15 | end 16 | 17 | it "returns no IPs" do 18 | expect(extension_for_cert.ips).to be_empty 19 | end 20 | 21 | it "returns no URIs" do 22 | expect(extension_for_cert.uris).to be_empty 23 | end 24 | end 25 | 26 | describe "for cert with extensions" do 27 | it "knows its DNS names" do 28 | expect(extension_for_cert_with_san.dns_names).to eq example_dns_names 29 | end 30 | 31 | it "knows its IPs" do 32 | expect(extension_for_cert_with_san.ips).to eq example_ips 33 | end 34 | 35 | it "knows its URIs" do 36 | expect(extension_for_cert_with_san.uris).to eq example_uris 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/rails/auth/error_page/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rails::Auth::ErrorPage::Middleware do 4 | let(:request) { Rack::MockRequest.env_for("https://www.example.com") } 5 | let(:error_page) { "

Unauthorized!!!

" } 6 | 7 | subject(:middleware) { described_class.new(app, page_body: error_page) } 8 | 9 | context "unspecified content type" do 10 | describe "access granted" do 11 | let(:code) { 200 } 12 | let(:app) { ->(env) { [code, env, "Hello, world!"] } } 13 | 14 | it "renders the expected response" do 15 | response = middleware.call(request) 16 | expect(response.first).to eq code 17 | end 18 | end 19 | 20 | describe "access denied" do 21 | let(:app) { ->(_env) { raise(Rails::Auth::NotAuthorizedError, "not authorized!") } } 22 | 23 | it "renders the error page" do 24 | code, _env, body = middleware.call(request) 25 | expect(code).to eq 403 26 | expect(body).to eq [error_page] 27 | end 28 | end 29 | end 30 | 31 | context "JSON content type" do 32 | let(:app) { ->(_env) { raise(Rails::Auth::NotAuthorizedError, "not authorized!") } } 33 | let(:message) { { message: "Access denied" }.to_json } 34 | 35 | context "via request path" do 36 | let(:request) { Rack::MockRequest.env_for("https://www.example.com/foobar.json?x=1&y=2") } 37 | 38 | it "renders a JSON response" do 39 | code, env, body = middleware.call(request) 40 | expect(code).to eq 403 41 | expect(env["Content-Type"]).to eq "application/json" 42 | expect(body).to eq [message] 43 | end 44 | end 45 | 46 | context "via Accept header" do 47 | it "renders a JSON response" do 48 | request["HTTP_ACCEPT"] = "application/json" 49 | 50 | code, env, body = middleware.call(request) 51 | expect(code).to eq 403 52 | expect(env["Content-Type"]).to eq "application/json" 53 | expect(body).to eq [message] 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/rails/auth/credentials_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rails::Auth::Credentials do 4 | let(:rack_env) { Rack::MockRequest.env_for("https://www.example.com") } 5 | 6 | let(:example_cn) { "127.0.0.1" } 7 | let(:example_ou) { "ponycopter" } 8 | 9 | let(:example_credential_type) { "x509" } 10 | let(:example_credential_value) { instance_double(Rails::Auth::X509::Certificate, cn: example_cn, ou: example_ou) } 11 | 12 | subject(:credentials) { described_class.new(example_credential_type => example_credential_value) } 13 | 14 | describe ".from_rack_env" do 15 | it "initializes from a Rack environment" do 16 | expect(described_class.from_rack_env(rack_env)).to be_a described_class 17 | end 18 | end 19 | 20 | describe "[]" do 21 | it "allows hash-like access to credentials" do 22 | expect(credentials[example_credential_type]).not_to be_blank 23 | end 24 | end 25 | 26 | context "when called twice for the same credential type" do 27 | let(:example_credential) { double(:credential1) } 28 | let(:second_credential) { double(:credential2) } 29 | 30 | let(:example_env) { Rack::MockRequest.env_for("https://www.example.com") } 31 | 32 | it "succeeds if the credentials are the same" do 33 | allow(example_credential).to receive(:==).and_return(true) 34 | 35 | Rails::Auth.add_credential(example_env, example_credential_type, example_credential) 36 | 37 | expect do 38 | Rails::Auth.add_credential(example_env, example_credential_type, second_credential) 39 | end.to_not raise_error 40 | end 41 | 42 | it "raises Rails::Auth::AlreadyAuthorizedError if the credentials are different" do 43 | allow(example_credential).to receive(:==).and_return(false) 44 | 45 | Rails::Auth.add_credential(example_env, example_credential_type, example_credential) 46 | 47 | expect do 48 | Rails::Auth.add_credential(example_env, example_credential_type, second_credential) 49 | end.to raise_error(Rails::Auth::AlreadyAuthorizedError) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/rails/auth/error_page/debug_middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "erb" 4 | require "cgi" 5 | 6 | module Rails 7 | module Auth 8 | module ErrorPage 9 | # Render a descriptive access denied page with debugging information about why the given 10 | # request was not authorized. Useful for debugging, but leaks information about your ACL 11 | # to a potential attacker. Make sure you're ok with that information being public. 12 | class DebugMiddleware 13 | # Configure CSP to disable JavaScript, but allow inline CSS 14 | # This is just in case someone pulls off reflective XSS, but hopefully all values are 15 | # properly escaped on the page so that won't happen. 16 | RESPONSE_HEADERS = { 17 | "Content-Type" => "text/html", 18 | "Content-Security-Policy" => 19 | "default-src 'self'; " \ 20 | "script-src 'none'; " \ 21 | "style-src 'unsafe-inline'" 22 | }.freeze 23 | 24 | def initialize(app, acl: nil) 25 | raise ArgumentError, "ACL must be a Rails::Auth::ACL" unless acl.is_a?(Rails::Auth::ACL) 26 | 27 | @app = app 28 | @acl = acl 29 | @erb = ERB.new(File.read(File.expand_path("debug_page.html.erb", __dir__))).freeze 30 | end 31 | 32 | def call(env) 33 | @app.call(env) 34 | rescue Rails::Auth::NotAuthorizedError 35 | [403, RESPONSE_HEADERS.dup, [error_page(env)]] 36 | end 37 | 38 | def error_page(env) 39 | credentials = Rails::Auth.credentials(env) 40 | resources = @acl.matching_resources(env) 41 | 42 | @erb.result(binding) 43 | end 44 | 45 | def h(text) 46 | CGI.escapeHTML(text || "") 47 | end 48 | 49 | def format_attributes(value) 50 | value.respond_to?(:attributes) ? value.attributes.inspect : value.inspect 51 | end 52 | 53 | def format_path(path) 54 | path.source.sub(/\A\\A/, "").sub(/\\z\z/, "") 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/rails/auth/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | # Modular resource-based authentication and authorization for Rails/Rack 5 | module Auth 6 | module_function 7 | 8 | # Mark a request as externally authorized. Causes ACL checks to be skipped. 9 | # 10 | # @param [Hash] :rack_env Rack environment 11 | # @param [String] :allowed_by what allowed the request 12 | # 13 | def authorized!(rack_env, allowed_by) 14 | Env.new(rack_env).tap do |env| 15 | env.authorize(allowed_by) 16 | end.to_rack 17 | end 18 | 19 | # Check whether a request has been authorized 20 | # 21 | # @param [Hash] :rack_env Rack environment 22 | # 23 | def authorized?(rack_env) 24 | Env.new(rack_env).authorized? 25 | end 26 | 27 | # Mark what authorized the request in the Rack environment 28 | # 29 | # @param [Hash] :rack_env Rack environment 30 | # @param [String] :allowed_by what allowed this request 31 | def set_allowed_by(rack_env, allowed_by) 32 | Env.new(rack_env).tap do |env| 33 | env.allowed_by = allowed_by 34 | end.to_rack 35 | end 36 | 37 | # Read what authorized the request 38 | # 39 | # @param [Hash] :rack_env Rack environment 40 | # 41 | # @return [String, nil] what authorized the request 42 | def allowed_by(rack_env) 43 | Env.new(rack_env).allowed_by 44 | end 45 | 46 | # Obtain credentials from a Rack environment 47 | # 48 | # @param [Hash] :rack_env Rack environment 49 | # 50 | def credentials(rack_env) 51 | Credentials.from_rack_env(rack_env) 52 | end 53 | 54 | # Add a credential to the Rack environment 55 | # 56 | # @param [Hash] :rack_env Rack environment 57 | # @param [String] :type credential type to add to the environment 58 | # @param [Object] :credential object to add to the environment 59 | # 60 | def add_credential(rack_env, type, credential) 61 | Env.new(rack_env).tap do |env| 62 | env.credentials[type] = credential 63 | end.to_rack 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /docs/Design-Overview.md: -------------------------------------------------------------------------------- 1 | Rails::Auth makes use of multiple, independent, single-purpose middleware 2 | classes to handle specific types of AuthN/AuthZ. 3 | 4 | ## AuthN 5 | 6 | Rails::Auth ships with the following AuthN middleware: 7 | 8 | * `Rails::Auth::X509::Middleware`: authenticates [[X.509]] certificates obtained 9 | from the Rack environment. 10 | 11 | The goal of Rails::Auth's AuthN middleware is to authenticate *credentials* 12 | taken from the Rack environment and place objects representing them under 13 | the `"rails-auth.credentials"` key within the Rack environment for use by 14 | subsequent AuthN or AuthZ middleware. The built-in support is for X.509 15 | client certificates, but other middleware could handle authentication of 16 | cookies or (OAuth) bearer credentials. 17 | 18 | The intended usage is to have multiple AuthN middlewares that are capable 19 | of extracting different types of credentials, but also allowing AuthZ 20 | middleware to apply a single policy to all of them. It's also possible to 21 | chain AuthN middleware together such that one credential obtained earlier 22 | in the middleware stack is used to authenticate another (for e.g. 23 | [channel-bound cookies]). 24 | 25 | [channel-bound cookies]: http://www.browserauth.net/channel-bound-cookies 26 | 27 | ## AuthZ 28 | 29 | Rails::Auth ships with one primary AuthZ middleware: 30 | 31 | * `Rails::Auth::ACL::Middleware`: support for [[Access Control Lists]] (ACLs). 32 | 33 | ACLs let you write a single, declarative policy for authorization in your application. ACLs are pluggable and let you write a single policy which can authorize access using different types of credentials. 34 | 35 | ACLs are a declarative approach to authorization, consolidating policies into a single file that can be easily audited by a security team without deep understanding of the many eccentricities of Rails. These policies 36 | provide coarse-grained authorization based on routes (as matched by regexes) and the credentials extracted by the AuthN middleware. However, the do not provide AuthZ which includes specific domain objects, or 37 | policies around them. For that we suggest using a library like [Pundit](https://github.com/elabs/pundit). -------------------------------------------------------------------------------- /docs/Access-Control-Lists.md: -------------------------------------------------------------------------------- 1 | Access Control Lists (ACLs) are the main tool Rails::Auth provides for AuthZ. ACLs use a set of route-by-route [[matchers]] to control access to particular resources. 2 | 3 | Rails::Auth encourages the use of YAML files for storing ACL definitions, although the use of YAML is not mandatory and the corresponding object structure output from `YAML.load` can be passed in instead. The following is an example of an ACL definition in YAML: 4 | 5 | ```yaml 6 | --- 7 | - resources: 8 | - method: ALL 9 | path: /foo/bar/.* 10 | allow_x509_subject: 11 | ou: ponycopter 12 | allow_claims: 13 | groups: ["example"] 14 | - resources: 15 | - method: ALL 16 | path: /_admin/?.* 17 | allow_claims: 18 | groups: ["admins"] 19 | - resources: 20 | - method: GET 21 | path: /internal/frobnobs/.* 22 | allow_x509_subject: 23 | ou: frobnobber 24 | - resources: 25 | - method: GET 26 | path: / 27 | allow_all: true 28 | ``` 29 | 30 | An ACL consists of a list of guard expressions, each of which contains a list of resources and a set of [[matchers]] which can authorize access to those resources. Access will be authorized if *any* of the matchers for a given resource are a match (i.e. matchers have "or"-like behavior, not "and"-like behavior). Requiring more than one credential to access a resource is not supported directly, but can be accomplished by having credential-extracting middleware check for credentials from previous middleware before adding new credentials to the Rack environment. 31 | 32 | Resources are defined by the following constraints: 33 | 34 | * **method**: The requested HTTP method, or `"ALL"` to allow any method 35 | * **path**: A regular expression to match the path. `\A` and `\z` are added by default to the beginning and end of the regex to ensure the entire path and not a substring is matched. 36 | * **host** (optional): a regular expression to match the `Host:` header passed by the client. Useful if your app services traffic for more than one hostname and you'd like to restrict ACLs by host. 37 | 38 | The following [[matchers]] are built-in and always available: 39 | 40 | * **allow_all**: (options: `true` or `false`) always allow requests to the 41 | given resources (so long as `true` is passed as the option) 42 | 43 | Rails::Auth also ships with [[matchers]] for [[X.509]] certificates. -------------------------------------------------------------------------------- /spec/rails/auth/x509/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logger" 4 | 5 | RSpec.describe Rails::Auth::X509::Middleware do 6 | let(:app) { ->(env) { [200, env, "Hello, world!"] } } 7 | let(:request) { Rack::MockRequest.env_for("https://www.example.com") } 8 | 9 | let(:cert_filter) { :pem } 10 | let(:cert_pem) { cert_path("valid.crt").read } 11 | let(:example_key) { "X-SSL-Client-Cert" } 12 | 13 | let(:middleware) do 14 | described_class.new( 15 | app, 16 | cert_filters: { example_key => cert_filter }, 17 | logger: Logger.new($stderr) 18 | ) 19 | end 20 | 21 | context "certificate types" do 22 | describe "PEM certificates" do 23 | it "extracts Rails::Auth::X509::Certificate from a PEM certificate in the Rack environment" do 24 | _response, env = middleware.call(request.merge(example_key => cert_pem)) 25 | 26 | credential = Rails::Auth.credentials(env).fetch("x509") 27 | expect(credential).to be_a Rails::Auth::X509::Certificate 28 | end 29 | 30 | it "normalizes abnormal whitespace" do 31 | _response, env = middleware.call(request.merge(example_key => cert_pem.tr("\n", "\t"))) 32 | 33 | credential = Rails::Auth.credentials(env).fetch("x509") 34 | expect(credential).to be_a Rails::Auth::X509::Certificate 35 | end 36 | end 37 | 38 | # :nocov: 39 | describe "Java certificates" do 40 | let(:cert_filter) { :java } 41 | let(:example_key) { "javax.servlet.request.X509Certificate" } 42 | 43 | let(:java_cert) do 44 | ruby_cert = OpenSSL::X509::Certificate.new(cert_pem) 45 | input_stream = Java::JavaIO::ByteArrayInputStream.new(ruby_cert.to_der.to_java_bytes) 46 | java_cert_klass = Java::JavaSecurityCert::CertificateFactory.getInstance("X.509") 47 | java_cert_klass.generateCertificate(input_stream) 48 | end 49 | 50 | it "extracts Rails::Auth::Credential::X509 from a java.security.cert.Certificate" do 51 | skip "JRuby only" unless defined?(JRUBY_VERSION) 52 | 53 | _response, env = middleware.call(request.merge(example_key => [java_cert])) 54 | 55 | credential = Rails::Auth.credentials(env).fetch("x509") 56 | expect(credential).to be_a Rails::Auth::X509::Certificate 57 | end 58 | end 59 | # :nocov: 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/rails/auth/env.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | # Wrapper for Rack environments with Rails::Auth helpers 6 | class Env 7 | # Rack environment key for marking external authorization 8 | AUTHORIZED_ENV_KEY = "rails-auth.authorized" 9 | 10 | # Rack environment key for storing what allowed the request 11 | ALLOWED_BY_ENV_KEY = "rails-auth.allowed-by" 12 | 13 | # Rack environment key for all rails-auth credentials 14 | CREDENTIALS_ENV_KEY = "rails-auth.credentials" 15 | 16 | attr_reader :allowed_by, :credentials 17 | 18 | # @param [Hash] :env Rack environment 19 | def initialize(env, credentials: {}, authorized: false, allowed_by: nil) 20 | raise TypeError, "expected Hash for credentials, got #{credentials.class}" unless credentials.is_a?(Hash) 21 | 22 | @env = env 23 | @credentials = Credentials.new(credentials.merge(@env.fetch(CREDENTIALS_ENV_KEY, {}))) 24 | @authorized = env.fetch(AUTHORIZED_ENV_KEY, authorized) 25 | @allowed_by = env.fetch(ALLOWED_BY_ENV_KEY, allowed_by) 26 | end 27 | 28 | # Check whether a request has been authorized 29 | def authorized? 30 | @authorized 31 | end 32 | 33 | # Mark the environment as authorized to access the requested resource 34 | # 35 | # @param [String] :allowed_by label of what allowed the request 36 | def authorize(allowed_by) 37 | self.allowed_by = allowed_by 38 | @authorized = true 39 | end 40 | 41 | # Set the name of the authority which authorized the request 42 | # 43 | # @param [String] :allowed_by label of what allowed the request 44 | def allowed_by=(allowed_by) 45 | raise AlreadyAuthorizedError, "already allowed by #{@allowed_by.inspect}" if @allowed_by 46 | raise TypeError, "expected String for allowed_by, got #{allowed_by.class}" unless allowed_by.is_a?(String) 47 | 48 | @allowed_by = allowed_by 49 | end 50 | 51 | # Return a Rack environment 52 | # 53 | # @return [Hash] Rack environment 54 | def to_rack 55 | @env[CREDENTIALS_ENV_KEY] = (@env[CREDENTIALS_ENV_KEY] || {}).merge(@credentials.to_hash) 56 | 57 | @env[AUTHORIZED_ENV_KEY] = @authorized if @authorized 58 | @env[ALLOWED_BY_ENV_KEY] = @allowed_by if @allowed_by 59 | 60 | @env 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/rails/auth/x509/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | module X509 6 | # Extracts X.509 client certificates and adds credential objects to the 7 | # rack environment as env["rails-auth.credentials"]["x509"] 8 | class Middleware 9 | # Create a new X.509 Middleware object 10 | # 11 | # @param [Object] app next app in the Rack middleware chain 12 | # @param [Hash] cert_filters maps Rack environment names to cert extractors 13 | # @param [Logger] logger place to log certificate extraction issues 14 | # 15 | # @return [Rails::Auth::X509::Middleware] new X509 middleware instance 16 | def initialize(app, cert_filters: {}, logger: nil) 17 | @app = app 18 | @cert_filters = cert_filters 19 | @logger = logger 20 | 21 | @cert_filters.each do |key, filter| 22 | next unless filter.is_a?(Symbol) 23 | 24 | # Convert snake_case to CamelCase 25 | filter_name = filter.to_s.split("_").map(&:capitalize).join 26 | 27 | # Shortcut syntax for symbols 28 | @cert_filters[key] = Rails::Auth::X509::Filter.const_get(filter_name).new 29 | end 30 | end 31 | 32 | def call(env) 33 | credential = extract_credential(env) 34 | Rails::Auth.add_credential(env, "x509", credential.freeze) if credential 35 | 36 | @app.call(env) 37 | end 38 | 39 | private 40 | 41 | def extract_credential(env) 42 | @cert_filters.each do |key, filter| 43 | cert = extract_certificate_with_filter(filter, env[key]) 44 | next unless cert 45 | 46 | return Rails::Auth::X509::Certificate.new(cert) 47 | end 48 | 49 | nil 50 | end 51 | 52 | def extract_certificate_with_filter(filter, raw_cert) 53 | case raw_cert 54 | when String then return if raw_cert.empty? 55 | when NilClass then return 56 | end 57 | 58 | filter.call(raw_cert) 59 | rescue StandardError => e 60 | @logger.debug("rails-auth: Certificate error: #{e.class}: #{e.message}") if @logger 61 | nil 62 | end 63 | 64 | def subject(cert) 65 | cert.subject.to_a.map { |attr, data| "#{attr}=#{data}" }.join(",") 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/rails/auth/rspec/helper_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | module RSpec 6 | # RSpec helper methods 7 | module HelperMethods 8 | # Credentials to be injected into the request during tests 9 | def test_credentials 10 | Rails.configuration.x.rails_auth.test_credentials 11 | end 12 | 13 | # Perform a test with the given credentials 14 | # NOTE: Credentials will be *cleared* after the block. Nesting is not allowed. 15 | def with_credentials(credentials = {}) 16 | raise TypeError, "expected Hash of credentials, got #{credentials.class}" unless credentials.is_a?(Hash) 17 | 18 | test_credentials.clear 19 | 20 | credentials.each do |type, value| 21 | test_credentials[type.to_s] = value 22 | end 23 | ensure 24 | test_credentials.clear 25 | end 26 | 27 | # Creates an Rails::Auth::X509::Certificate instance double 28 | def x509_certificate(cn: nil, ou: nil) 29 | subject = "" 30 | subject += "CN=#{cn}" if cn 31 | subject += "OU=#{ou}" if ou 32 | 33 | instance_double(Rails::Auth::X509::Certificate, subject, cn: cn, ou: ou).tap do |certificate| 34 | allow(certificate).to receive(:[]) do |key| 35 | { 36 | "CN" => cn, 37 | "OU" => ou 38 | }[key.to_s.upcase] 39 | end 40 | end 41 | end 42 | 43 | # Creates a certificates hash containing a single X.509 certificate instance double 44 | def x509_certificate_hash(**args) 45 | { "x509" => x509_certificate(**args) } 46 | end 47 | 48 | Rails::Auth::ACL::Resource::HTTP_METHODS.each do |method| 49 | define_method("#{method.downcase}_request") do |credentials: {}| 50 | path = self.class.description 51 | 52 | # Warn if methods are improperly used 53 | raise ArgumentError, "expected #{path} to start with '/'" unless path.chars[0] == "/" 54 | 55 | env = { 56 | "REQUEST_METHOD" => method, 57 | "PATH_INFO" => self.class.description 58 | } 59 | 60 | credentials.each do |type, value| 61 | Rails::Auth.add_credential(env, type.to_s, value) 62 | end 63 | 64 | env 65 | end 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/rails/auth/rspec/helper_methods_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ostruct" 4 | 5 | RSpec.describe Rails::Auth::RSpec::HelperMethods, acl_spec: true do 6 | let(:example_cn) { "127.0.0.1" } 7 | let(:example_ou) { "ponycopter" } 8 | 9 | before do 10 | credentials = {} 11 | rails_auth = double("config", test_credentials: credentials) 12 | x_config = double("config", rails_auth: rails_auth) 13 | configuration = double("config", x: x_config) 14 | 15 | allow(Rails).to receive(:configuration).and_return(configuration) 16 | end 17 | 18 | describe "#with_credentials" do 19 | let(:example_credential_type) { :x509 } 20 | let(:example_credential_value) { x509_certificate(cn: example_cn, ou: example_ou) } 21 | 22 | it "sets credentials in the Rails config" do 23 | expect(test_credentials[example_credential_type]).to be_nil 24 | 25 | with_credentials(example_credential_type => example_credential_value) do 26 | expect(test_credentials[example_credential_type]).to be example_credential_value 27 | end 28 | 29 | expect(test_credentials[example_credential_type]).to be_nil 30 | end 31 | end 32 | 33 | describe "#x509_certificate" do 34 | subject { x509_certificate(cn: example_cn, ou: example_ou) } 35 | 36 | it "creates instance doubles for Rails::Auth::X509::Certificates" do 37 | # Method syntax 38 | expect(subject.cn).to eq example_cn 39 | expect(subject.ou).to eq example_ou 40 | 41 | # Hash-like syntax 42 | expect(subject[:cn]).to eq example_cn 43 | expect(subject[:ou]).to eq example_ou 44 | end 45 | end 46 | 47 | describe "#x509_certificate_hash" do 48 | subject { x509_certificate_hash(cn: example_cn, ou: example_ou) } 49 | 50 | it "creates a certificate hash with an Rails::Auth::X509::Certificate double" do 51 | expect(subject["x509"].cn).to eq example_cn 52 | end 53 | end 54 | 55 | Rails::Auth::ACL::Resource::HTTP_METHODS.each do |method| 56 | describe "##{method.downcase}_request" do 57 | it "returns a Rack environment" do 58 | # These methods introspect self.class.description to find the path 59 | allow(self.class).to receive(:description).and_return("/") 60 | env = method("#{method.downcase}_request").call 61 | 62 | expect(env["REQUEST_METHOD"]).to eq method 63 | end 64 | 65 | it "raises ArgumentError if the description doesn't start with /" do 66 | expect { method("#{method.downcase}_request").call }.to raise_error(ArgumentError) 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /docs/Comparison-With-Other-Libraries.md: -------------------------------------------------------------------------------- 1 | Rails::Auth was primarily intended for use in environments with the following: 2 | 3 | * [Microservices]: Rails::Auth is primarily intended to support environments with many services written as Rails apps which need to make authenticated requests to each other. Square uses Rails::Auth in an environment where we have many Rails microservices (and microservices written in other languages) authenticating to each other with [[X.509]] certificates. 4 | * [Claims-Based Identity]: Rails::Auth is designed to work in conjunction with a central [Single Sign-On] (SSO) system which issues credentials that provide user identities. Rails::Auth does not ship with a specific implementation of an SSO system, but makes it easy to integrate with existing ones. 5 | 6 | Below is a comparison of how Rails::Auth relates to the existing landscape of Rails AuthN and AuthZ libraries. These are grouped into two different categories: libraries Rails::Auth replaces, and libraries with which 7 | Rails::Auth can be used in a complementary fashion. 8 | 9 | ## Replaces: 10 | 11 | * [Warden]: Uses a single "opinionated" Rack middleware providing user-centric authentication and methods that allow controllers to imperatively interrogate the authentication context for authorization purposes. By comparison Rails::Auth is not prescriptive and much more flexible about credential types (supporting credentials for both user and service clients) and uses declarative authorization policies in the form of ACLs. 12 | 13 | * [Devise]: A mature, flexible, expansive framework primarily intended for user authentication. Some of the same caveats as Warden apply, however Devise provides a framework for modeling users within a Rails app along with common authentication flows, making it somewhat orthogonal to what Rails::Auth provides. Rails::Auth is designed to easily support [claims-based identity] systems where user identity is outsourced to a separate microservice. 14 | 15 | ## Complements: 16 | 17 | * [Pundit]: Domain object-centric fine-grained authorization using clean object-oriented APIs. Pundit makes authorization decisions around particular objects based on policy objects and contexts. Rails::Auth's credentials can be used as a powerful policy context for Pundit. 18 | 19 | * [CanCanCan]: a continuation of the popular CanCan AuthZ library after a period of neglect. Uses a more DSL-like approach to AuthZ than Pundit, but provides many facilities similar to Pundit for domain object-centric 20 | AuthZ. 21 | 22 | [Warden]: https://github.com/hassox/warden/wiki 23 | [Devise]: https://github.com/plataformatec/devise 24 | [Pundit]: https://github.com/elabs/pundit 25 | [CanCanCan]: https://github.com/CanCanCommunity/cancancan 26 | 27 | [microservices]: http://martinfowler.com/articles/microservices.html 28 | [claims-based identity]: https://en.wikipedia.org/wiki/Claims-based_identity 29 | [single sign-on]: https://en.wikipedia.org/wiki/Single_sign-on -------------------------------------------------------------------------------- /lib/rails/auth/x509/certificate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | module X509 6 | # X.509 client certificates obtained from HTTP requests 7 | class Certificate 8 | attr_reader :certificate 9 | 10 | def initialize(certificate) 11 | unless certificate.is_a?(OpenSSL::X509::Certificate) 12 | raise TypeError, "expecting OpenSSL::X509::Certificate, got #{certificate.class}" 13 | end 14 | 15 | @certificate = certificate.freeze 16 | @subject = {} 17 | 18 | @certificate.subject.to_a.each do |name, data, _type| 19 | @subject[name.freeze] = data.freeze 20 | end 21 | @subject_alt_names = SubjectAltNameExtension.new(certificate) 22 | @subject_alt_names.freeze 23 | @subject.freeze 24 | end 25 | 26 | def [](component) 27 | @subject[component.to_s.upcase] 28 | end 29 | 30 | def cn 31 | @subject["CN"] 32 | end 33 | alias common_name cn 34 | 35 | def dns_names 36 | @subject_alt_names.dns_names 37 | end 38 | 39 | def ips 40 | @subject_alt_names.ips 41 | end 42 | 43 | def ou 44 | @subject["OU"] 45 | end 46 | alias organizational_unit ou 47 | 48 | def uris 49 | @subject_alt_names.uris 50 | end 51 | 52 | # According to the SPIFFE standard only one SPIFFE ID can exist in the URI 53 | # SAN: 54 | # (https://github.com/spiffe/spiffe/blob/master/standards/X509-SVID.md#2-spiffe-id) 55 | # 56 | # @return [String, nil] string containing SPIFFE ID if one is present 57 | # in the certificate 58 | def spiffe_id 59 | uris.detect { |uri| uri.start_with?("spiffe://") } 60 | end 61 | 62 | # Generates inspectable attributes for debugging 63 | # 64 | # @return [Hash] hash containing parts of the certificate subject (cn, ou) 65 | # and subject alternative name extension (uris, dns_names) as well 66 | # as SPIFFE ID (spiffe_id), which is just a convenience since those 67 | # are already included in the uris 68 | def attributes 69 | { 70 | cn: cn, 71 | dns_names: dns_names, 72 | ips: ips, 73 | ou: ou, 74 | spiffe_id: spiffe_id, 75 | uris: uris 76 | }.reject { |_, v| v.nil? || v.empty? } 77 | end 78 | 79 | # Compare ourself to another object by ensuring that it has the same type 80 | # and that its certificate pem is the same as ours 81 | def ==(other) 82 | other.is_a?(self.class) && other.certificate.to_der == certificate.to_der 83 | end 84 | 85 | alias eql? == 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /docs/X.509.md: -------------------------------------------------------------------------------- 1 | Rails::Auth is designed to support microservice ecosystems identified by [X.509 Certificates](https://en.wikipedia.org/wiki/X.509). This provides strong cryptographic authentication when used in conjunction with [HTTPS](https://en.wikipedia.org/wiki/HTTPS). To use Rails::Auth in this capacity, you will need to set up an internal [certificate authority](https://en.wikipedia.org/wiki/Certificate_authority) (using e.g. [cfssl](https://github.com/cloudflare/cfssl) or [openssl ca](https://www.openssl.org/docs/manmaster/apps/ca.html)) and then create an X.509 certificate for each microservice in your infrastructure. 2 | 3 | ## ACL Matcher 4 | 5 | To enable X.509 support in Rails::Auth, pass in the [[matcher|matchers]] class for X.509 certificates: `Rails::Auth::X509::Matcher` class when creating your [[Access Control List|Access Control Lists]] object, along with a key name you'll use in your ACL like `allow_x509_subject`. 6 | 7 | Now when you define your ACL, you can restrict access to particular routes based on the client's X.509 certificate: 8 | 9 | ```yaml 10 | --- 11 | - resources: 12 | - method: ALL 13 | path: /foo/bar/.* 14 | allow_x509_subject: 15 | ou: ponycopter 16 | ``` 17 | 18 | The following options can be passed to the matcher: 19 | 20 | * **cn:** common name of the certificate, e.g. app name or app/host 21 | * **ou:** organizational unit name of the certificate, e.g. app name or team name 22 | 23 | ## cert_filters 24 | 25 | For [[X.509]] client certificate-based authentication to work, you will need to configure your web server to include them in your Rack environment, and also configure `cert_filters` correctly to filter and process them from the Rack environment. 26 | 27 | For example, if you're using nginx + Passenger, you'll need to add something like the following to your nginx configuration: 28 | 29 | ``` 30 | passenger_set_cgi_param X-SSL-Client-Cert $ssl_client_raw_cert; 31 | ``` 32 | 33 | Once the client certificate is in the Rack environment in some form, you'll need to configure a filter object which can convert it from its Rack environment form into an `OpenSSL::X509::Certificate` instance. There are 34 | two built in filters you can reference as symbols to do this: 35 | 36 | * `:pem`: parses certificates from the Privacy Enhanced Mail format 37 | * `:java`: converts `sun.security.x509.X509CertImpl` certificate chains 38 | 39 | The `cert_filters` parameter is a mapping of Rack environment names to corresponding filters: 40 | 41 | ```ruby 42 | cert_filters: { "X-SSL-Client-Cert" => :pem } 43 | ``` 44 | 45 | In addition to these symbols, a filter can be any object that responds to the `#call` method, such as a `Proc`. The following filter will parse PEM certificates: 46 | 47 | ```ruby 48 | cert_filters: { 49 | "X-SSL-Client-Cert" => proc do |pem| 50 | OpenSSL::X509::Certificate.new(pem) 51 | end 52 | } 53 | ``` 54 | 55 | When certificates are recognized and verified, a `Rails::Auth::X509::Certificate` object will be added to the Rack environment under `env["rails-auth.credentials"]["x509"]`. This middleware will never add any certificate to the environment's credentials that hasn't been verified against the configured CA bundle. -------------------------------------------------------------------------------- /spec/support/create_certs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "certificate_authority" 4 | require "fileutils" 5 | 6 | cert_path = File.expand_path("../../tmp/certs", __dir__) 7 | FileUtils.mkdir_p(cert_path) 8 | 9 | # 10 | # Create CA certificate 11 | # 12 | 13 | ca = CertificateAuthority::Certificate.new 14 | 15 | ca.subject.common_name = "cacertificate.com" 16 | ca.serial_number.number = 1 17 | ca.key_material.generate_key 18 | ca.signing_entity = true 19 | 20 | ca.sign! "extensions" => { "keyUsage" => { "usage" => %w[critical keyCertSign] } } 21 | 22 | ca_cert_path = File.join(cert_path, "ca.crt") 23 | ca_key_path = File.join(cert_path, "ca.key") 24 | 25 | File.write ca_cert_path, ca.to_pem 26 | File.write ca_key_path, ca.key_material.private_key.to_pem 27 | 28 | # 29 | # Valid client certificate 30 | # 31 | 32 | valid_cert = CertificateAuthority::Certificate.new 33 | valid_cert.subject.common_name = "127.0.0.1" 34 | valid_cert.subject.organizational_unit = "ponycopter" 35 | valid_cert.serial_number.number = 2 36 | valid_cert.key_material.generate_key 37 | valid_cert.parent = ca 38 | valid_cert.sign! 39 | 40 | valid_cert_path = File.join(cert_path, "valid.crt") 41 | valid_key_path = File.join(cert_path, "valid.key") 42 | 43 | File.write valid_cert_path, valid_cert.to_pem 44 | File.write valid_key_path, valid_cert.key_material.private_key.to_pem 45 | 46 | # 47 | # Valid client certificate with extensions 48 | # 49 | 50 | valid_cert_with_ext = CertificateAuthority::Certificate.new 51 | valid_cert_with_ext.subject.common_name = "127.0.0.1" 52 | valid_cert_with_ext.subject.organizational_unit = "ponycopter" 53 | valid_cert_with_ext.serial_number.number = 3 54 | valid_cert_with_ext.key_material.generate_key 55 | signing_profile = { 56 | "extensions" => { 57 | "basicConstraints" => { 58 | "ca" => false 59 | }, 60 | "crlDistributionPoints" => { 61 | "uri" => "http://notme.com/other.crl" 62 | }, 63 | "subjectKeyIdentifier" => {}, 64 | "authorityKeyIdentifier" => {}, 65 | "authorityInfoAccess" => { 66 | "ocsp" => %w[http://youFillThisOut/ocsp/] 67 | }, 68 | "keyUsage" => { 69 | "usage" => %w[digitalSignature keyEncipherment dataEncipherment] 70 | }, 71 | "extendedKeyUsage" => { 72 | "usage" => %w[serverAuth clientAuth] 73 | }, 74 | "subjectAltName" => { 75 | "uris" => %w[spiffe://example.com/exemplar https://www.example.com/page1 https://www.example.com/page2], 76 | "ips" => %w[0.0.0.0 127.0.0.1 192.168.1.1], 77 | "dns_names" => %w[example.com exemplar.com somethingelse.com] 78 | }, 79 | "certificatePolicies" => { 80 | "policy_identifier" => "1.3.5.8", 81 | "cps_uris" => %w[http://my.host.name/ http://my.your.name/], 82 | "user_notice" => { 83 | "explicit_text" => "Explicit Text Here", 84 | "organization" => "Organization name", 85 | "notice_numbers" => "1,2,3,4" 86 | } 87 | } 88 | } 89 | } 90 | valid_cert_with_ext.parent = ca 91 | valid_cert_with_ext.sign!(signing_profile) 92 | 93 | valid_cert_with_ext_path = File.join(cert_path, "valid_with_ext.crt") 94 | valid_key_with_ext_path = File.join(cert_path, "valid_with_ext.key") 95 | 96 | File.write valid_cert_with_ext_path, valid_cert_with_ext.to_pem 97 | File.write valid_key_with_ext_path, valid_cert_with_ext.key_material.private_key.to_pem 98 | -------------------------------------------------------------------------------- /lib/rails/auth/acl/resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | class ACL 6 | # Rules for a particular route 7 | class Resource 8 | attr_reader :http_methods, :path, :host, :matchers 9 | 10 | # Valid HTTP methods 11 | HTTP_METHODS = %w[GET HEAD PUT POST DELETE OPTIONS PATCH LINK UNLINK].freeze 12 | 13 | # Options allowed for resource matchers 14 | VALID_OPTIONS = %w[method path host].freeze 15 | 16 | # @option :options [String] :method HTTP method allowed ("ALL" for all methods) 17 | # @option :options [String] :path path to the resource (regex syntax allowed) 18 | # @param [Hash] :matchers which matchers are used for this resource 19 | # 20 | def initialize(options, matchers) 21 | raise TypeError, "expected Hash for options" unless options.is_a?(Hash) 22 | raise TypeError, "expected Hash for matchers" unless matchers.is_a?(Hash) 23 | 24 | unless (extra_keys = options.keys - VALID_OPTIONS).empty? 25 | raise ParseError, "unrecognized key in ACL resource: #{extra_keys.first}" 26 | end 27 | 28 | methods = options["method"] || raise(ParseError, "no 'method' key in resource: #{options.inspect}") 29 | path = options["path"] || raise(ParseError, "no 'path' key in resource: #{options.inspect}") 30 | 31 | @http_methods = extract_methods(methods) 32 | @path = /\A#{path}\z/ 33 | @matchers = matchers.freeze 34 | 35 | # Unlike method and path, host is optional 36 | host = options["host"] 37 | @host = /\A#{host}\z/ if host 38 | end 39 | 40 | # Match this resource against the given Rack environment, checking all 41 | # matchers to ensure at least one of them matches 42 | # 43 | # @param [Hash] :env Rack environment 44 | # 45 | # @return [String, nil] name of the matcher which matched, or nil if none matched 46 | # 47 | def match(env) 48 | return nil unless match!(env) 49 | 50 | name, = @matchers.find { |_name, matcher| matcher.match(env) } 51 | name 52 | end 53 | 54 | # Match *only* the request method/path/host against the given Rack environment. 55 | # matchers are NOT checked. 56 | # 57 | # @param [Hash] :env Rack environment 58 | # 59 | # @return [Boolean] method and path *only* match the given environment 60 | # 61 | def match!(env) 62 | return false unless @http_methods.include?(env["REQUEST_METHOD"]) 63 | return false unless @path =~ env["PATH_INFO"] 64 | return false unless @host.nil? || @host =~ env["HTTP_HOST"] 65 | 66 | true 67 | end 68 | 69 | private 70 | 71 | def extract_methods(methods) 72 | methods = Array(methods) 73 | 74 | return HTTP_METHODS if methods == ["ALL"] 75 | raise ParseError, "method 'ALL' cannot be used with other methods" if methods.include?("ALL") 76 | 77 | methods.each do |method| 78 | raise ParseError, "invalid HTTP method: #{method}" unless HTTP_METHODS.include?(method) 79 | end 80 | 81 | methods.freeze 82 | end 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/rails/auth/config_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Auth 5 | # Configures Rails::Auth middleware for use in a Rails application 6 | module ConfigBuilder 7 | extend self 8 | 9 | # Application-level configuration (i.e. config/application.rb) 10 | def application(config, acl_file: Rails.root.join("config/acl.yml"), matchers: {}) 11 | config.x.rails_auth.acl = Rails::Auth::ACL.from_yaml( 12 | File.read(acl_file.to_s), 13 | matchers: matchers 14 | ) 15 | 16 | config.middleware.use Rails::Auth::ACL::Middleware, acl: config.x.rails_auth.acl 17 | end 18 | 19 | # Development configuration (i.e. config/environments/development.rb) 20 | def development(config, development_credentials: {}, error_page: :debug) 21 | error_page_middleware(config, error_page) 22 | credential_injector_middleware(config, development_credentials) unless development_credentials.empty? 23 | end 24 | 25 | # Test configuration (i.e. config/environments/test.rb) 26 | def test(config) 27 | # Simulated credentials to be injected with InjectorMiddleware 28 | credential_injector_middleware(config, config.x.rails_auth.test_credentials ||= {}) 29 | end 30 | 31 | def production( 32 | config, 33 | cert_filters: nil, 34 | error_page: Rails.root.join("public/403.html"), 35 | monitor: nil 36 | ) 37 | error_page_middleware(config, error_page) 38 | 39 | if cert_filters 40 | config.middleware.insert_before Rails::Auth::ACL::Middleware, 41 | Rails::Auth::X509::Middleware, 42 | cert_filters: cert_filters, 43 | logger: Rails.logger 44 | end 45 | 46 | return unless monitor 47 | 48 | config.middleware.insert_before Rails::Auth::ACL::Middleware, 49 | Rails::Auth::Monitor::Middleware, 50 | monitor 51 | end 52 | 53 | private 54 | 55 | # Adds error page middleware to the chain 56 | def error_page_middleware(config, error_page) 57 | case error_page 58 | when :debug 59 | config.middleware.insert_before Rails::Auth::ACL::Middleware, 60 | Rails::Auth::ErrorPage::DebugMiddleware, 61 | acl: config.x.rails_auth.acl 62 | when Pathname, String 63 | config.middleware.insert_before Rails::Auth::ACL::Middleware, 64 | Rails::Auth::ErrorPage::Middleware, 65 | page_body: Pathname(error_page).read 66 | when FalseClass, NilClass 67 | nil 68 | else raise TypeError, "bad error page mode: #{mode.inspect}" 69 | end 70 | end 71 | 72 | # Adds Rails::Auth::Credentials::InjectorMiddleware to the chain with the given credentials 73 | def credential_injector_middleware(config, credentials) 74 | config.middleware.insert_before Rails::Auth::ACL::Middleware, 75 | Rails::Auth::Credentials::InjectorMiddleware, 76 | credentials 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/rails/auth/error_page/debug_page.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Rails::Auth: Access Denied 6 | 51 | 52 | 53 | 54 | 58 | 59 |
60 |
61 |

Error: Access to the requested resource is not allowed with your current credentials.

62 |

Below is information about the request you made and the credentials you sent:

63 |
64 | 65 |
66 |

Request:

67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 |
Method<%= h(env["REQUEST_METHOD"]) %>
Path<%= h(env["PATH_INFO"]) %>
Host<%= h(env["HTTP_HOST"]) %>
82 |
83 | 84 |
85 |

Credentials:

86 | 87 | <% if credentials.empty? %> 88 |

No credentials provided! This is a likely cause of this error.

89 |

Please retry the request with proper credentials.

90 | <% else %> 91 | 92 | <% credentials.each do |name, credential| %> 93 | 94 | 95 | 96 | 97 | <% end %> 98 |
<%= h(name) %><%= h(format_attributes(credential)) %>
99 | <% end %> 100 |
101 | 102 |
103 |

Authorized ACL Entries:

104 | <% if resources.empty? %> 105 |

Error: No matching resources! This is a likely cause of this error.

106 |

Please check your ACL and make sure there's an entry for this route.

107 | <% else %> 108 |

The following entries in your ACL are authorized to view this paritcular route:

109 | 110 | 111 | <% resources.each do |resource| %> 112 | 113 | 114 | 121 | 122 | <% end %> 123 |
<%= h((resource.http_methods || "ALL").join(" ")) %> <%= h(format_path(resource.path)) %> 115 |
    116 | <% resource.matchers.each do |name, matcher| %> 117 |
  • <%= h(name) %>: <%= h(format_attributes(matcher)) %>
  • 118 | <% end %> 119 |
120 |
124 | <% end %> 125 |
126 |
127 | 128 | 129 | -------------------------------------------------------------------------------- /lib/rails/auth/acl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Pull in default matchers 4 | require "rails/auth/acl/matchers/allow_all" 5 | 6 | module Rails 7 | module Auth 8 | # Route-based access control lists 9 | class ACL 10 | attr_reader :resources 11 | 12 | # Matchers available by default in ACLs 13 | DEFAULT_MATCHERS = { 14 | allow_all: Matchers::AllowAll 15 | }.freeze 16 | 17 | # Create a Rails::Auth::ACL from a YAML representation of an ACL 18 | # 19 | # @param [String] :yaml serialized YAML to load an ACL from 20 | def self.from_yaml(yaml, **args) 21 | require "yaml" 22 | new( 23 | if YAML::VERSION >= "4.0" 24 | YAML.safe_load(yaml, aliases: true) 25 | else 26 | YAML.safe_load(yaml, [], [], true) 27 | end, 28 | **args 29 | ) 30 | end 31 | 32 | # @param [Array] :acl Access Control List configuration 33 | # @param [Hash] :matchers authorizers use with this ACL 34 | # 35 | def initialize(acl, matchers: {}) 36 | raise TypeError, "expected Array for acl, got #{acl.class}" unless acl.is_a?(Array) 37 | 38 | @resources = [] 39 | 40 | acl.each do |entry| 41 | raise TypeError, "expected Hash for acl entry, got #{entry.class}" unless entry.is_a?(Hash) 42 | 43 | resources = entry["resources"] 44 | raise ParseError, "no 'resources' key present in entry: #{entry.inspect}" unless resources 45 | 46 | matcher_instances = parse_matchers(entry, matchers.merge(DEFAULT_MATCHERS)) 47 | 48 | resources.each do |resource| 49 | @resources << Resource.new(resource, matcher_instances).freeze 50 | end 51 | end 52 | 53 | @resources.freeze 54 | end 55 | 56 | # Match the Rack environment against the ACL, checking all matchers 57 | # 58 | # @param [Hash] :env Rack environment 59 | # 60 | # @return [String, nil] name of the first matching matcher, or nil if unauthorized 61 | # 62 | def match(env) 63 | @resources.each do |resource| 64 | matcher_name = resource.match(env) 65 | return matcher_name if matcher_name 66 | end 67 | 68 | nil 69 | end 70 | 71 | # Find all resources that match the ACL. Matchers are *NOT* checked, 72 | # instead only the initial checks for the "resources" section of the ACL 73 | # are performed. Use the `#match` method to validate matchers. 74 | # 75 | # This method is intended for debugging AuthZ failures. It can find all 76 | # resources that match the given request so the corresponding matchers 77 | # can be introspected. 78 | # 79 | # @param [Hash] :env Rack environment 80 | # 81 | # @return [Array] matching resources 82 | # 83 | def matching_resources(env) 84 | @resources.find_all { |resource| resource.match!(env) } 85 | end 86 | 87 | private 88 | 89 | def parse_matchers(entry, matchers) 90 | matcher_instances = {} 91 | 92 | entry.each do |name, options| 93 | next if name == "resources" 94 | 95 | matcher_class = matchers[name.to_sym] 96 | raise ArgumentError, "no matcher for #{name}" unless matcher_class 97 | raise TypeError, "expected Class for #{name}" unless matcher_class.is_a?(Class) 98 | 99 | matcher_instances[name.freeze] = matcher_class.new(options.freeze).freeze 100 | end 101 | 102 | matcher_instances.freeze 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Rails::Auth 2 | =========== 3 | [![Gem Version](https://badge.fury.io/rb/rails-auth.svg)](http://rubygems.org/gems/rails-auth) 4 | [![Build Status](https://travis-ci.org/square/rails-auth.svg?branch=master)](https://travis-ci.org/square/rails-auth) 5 | [![Code Climate](https://codeclimate.com/github/square/rails-auth/badges/gpa.svg)](https://codeclimate.com/github/square/rails-auth) 6 | [![Coverage Status](https://coveralls.io/repos/github/square/rails-auth/badge.svg?branch=master)](https://coveralls.io/github/square/rails-auth?branch=master) 7 | [![Apache 2 licensed](https://img.shields.io/badge/license-Apache2-blue.svg)](https://github.com/square/rails-auth/blob/master/LICENSE) 8 | 9 | Modular resource-based authentication and authorization for Rails/Rack designed 10 | to support [microservice] authentication and [claims-based identity]. 11 | 12 | [microservice]: http://martinfowler.com/articles/microservices.html 13 | [claims-based identity]: https://en.wikipedia.org/wiki/Claims-based_identity 14 | 15 | ## Description 16 | 17 | Rails::Auth is a flexible library designed for both authentication (AuthN) and authorization (AuthZ) using Rack Middleware. 18 | It [splits AuthN and AuthZ steps into separate middleware classes][design overview], using AuthN middleware to first verify 19 | credentials (such as X.509 certificates or cookies), then authorizing the request via separate AuthZ middleware that 20 | consumes these credentials, e.g. [access control lists][acls] (ACLs). 21 | 22 | Rails::Auth can be used to authenticate and authorize end users using browser cookies, service-to-service requests using 23 | [X.509 client certificates][x509], or any other clients with credentials that have proper authenticating middleware. 24 | 25 | Despite what the name may lead you to believe, Rails::Auth also [works well with other Rack-based frameworks][rack] 26 | like Sinatra. 27 | 28 | [design overview]: https://github.com/square/rails-auth/wiki/Design-Overview 29 | [acls]: https://github.com/square/rails-auth/wiki/Access-Control-Lists 30 | [x509]: https://github.com/square/rails-auth/wiki/X.509 31 | [rack]: https://github.com/square/rails-auth/wiki/Rack-Usage 32 | 33 | ## Installation 34 | 35 | Add this line to your application's Gemfile: 36 | 37 | ```ruby 38 | gem 'rails-auth' 39 | ``` 40 | 41 | And then execute: 42 | 43 | $ bundle 44 | 45 | Or install it yourself as: 46 | 47 | $ gem install rails-auth 48 | 49 | ## Comparison to other Rails/Rack auth libraries/frameworks 50 | 51 | For a comparison of Rails::Auth to other Rails auth libraries, including 52 | complimentary libraries and those that Rails::Auth overlaps/competes with, 53 | please see this page on the Wiki: 54 | 55 | [Comparison With Other Libraries](https://github.com/square/rails-auth/wiki/Comparison-With-Other-Libraries) 56 | 57 | ## Documentation 58 | 59 | Documentation can be found on the Wiki at: https://github.com/square/rails-auth/wiki 60 | 61 | YARD documentation is also available: http://www.rubydoc.info/github/square/rails-auth/master 62 | 63 | Please see the following page for how to add Rails::Auth to a Rails app: 64 | 65 | [Rails Usage](https://github.com/square/rails-auth/wiki/Rails-Usage) 66 | 67 | ## Contributing 68 | 69 | Any contributors to the master *rails-auth* repository must sign the 70 | [Individual Contributor License Agreement (CLA)]. It's a short form that covers 71 | our bases and makes sure you're eligible to contribute. 72 | 73 | When you have a change you'd like to see in the master repository, send a 74 | [pull request]. Before we merge your request, we'll make sure you're in the list 75 | of people who have signed a CLA. 76 | 77 | [Individual Contributor License Agreement (CLA)]: https://spreadsheets.google.com/spreadsheet/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1 78 | [pull request]: https://github.com/square/rails-auth/pulls 79 | 80 | ## License 81 | 82 | Copyright (c) 2016 Square Inc. Distributed under the Apache 2.0 License. 83 | See LICENSE file for further details. 84 | -------------------------------------------------------------------------------- /docs/RSpec-Support.md: -------------------------------------------------------------------------------- 1 | Rails::Auth includes RSpec support useful for writing Rails integration tests and/or spec for your ACLs to ensure they have the behavior you expect. 2 | 3 | To enable RSpec support, require the following: 4 | 5 | ```ruby 6 | require "rails/auth/rspec" 7 | ``` 8 | 9 | ## Helper Methods 10 | 11 | ### `with_credentials`: simulate credentials in tests 12 | 13 | Configures a `Hash` of credentials (or doubles of them) to be used in the test. Helpful for simulating various scenarios in integration tests: 14 | 15 | ```ruby 16 | RSpec.describe MyApiController, type: :request do 17 | describe "#index" do 18 | let(:example_app) { "foobar" } 19 | let(:another_app) { "quux" } 20 | 21 | it "permits access to the 'foobar' app" do 22 | with_credentials(x509: x509_certificate(cn: example_app)) do 23 | get my_api_path 24 | end 25 | 26 | expect(response.code).to eq "200" 27 | end 28 | 29 | it "disallows the 'quux' app" do 30 | with_credentials(x509: x509_certificate(cn: another_app)) do 31 | get my_api_path 32 | end 33 | 34 | expect(response.code).to eq "403" 35 | end 36 | end 37 | end 38 | ``` 39 | 40 | ### `x509_certificate`, `x509_certificate_hash`: create X.509 certificate doubles 41 | 42 | The `x509_certificate` method creates a [verifying double] of a `Rails::Auth::X509::Certificate`. See the `#with_credentials` example for use in context. 43 | 44 | It accepts the following options: 45 | 46 | * **cn**: common name of the certificate (e.g. app name or app/host combo) 47 | * **ou**: organizational unit of the certificate (e.g. app name, team name) 48 | 49 | The `x509_certificate_hash` method produces a credential hash containing a `Rails::Auth::X509::Certificate`, and is shorthand so you don't have to do `{"x509" => x509_certificate(...)}`. Below is the same example as from `#with_credentials`, but rewritten with the `x509_certificate_hash` shorthand: 50 | 51 | ```ruby 52 | RSpec.describe MyApiController, type: :request do 53 | describe "#index" do 54 | let(:example_app) { "foobar" } 55 | let(:another_app) { "quux" } 56 | 57 | it "permits access to the 'foobar' app" do 58 | with_credentials(x509_certificate_hash(cn: example_app)) do 59 | get my_api_path 60 | end 61 | 62 | expect(response.code).to eq "200" 63 | end 64 | 65 | it "disallows the 'quux' app" do 66 | with_credentials(x509_certificate_hash(cn: another_app)) do 67 | get my_api_path 68 | end 69 | 70 | expect(response.code).to eq "403" 71 | end 72 | end 73 | end 74 | ``` 75 | 76 | See also: [[X.509]] Wiki page. 77 | 78 | [verifying double]: https://relishapp.com/rspec/rspec-mocks/docs/verifying-doubles 79 | 80 | ## ACL Specs 81 | 82 | Rails::Auth provides its own extensions to RSpec to allow you to write specs for the behavior of ACLs. 83 | 84 | Below is an example of how to write an ACL spec: 85 | 86 | ```ruby 87 | RSpec.describe "example_acl.yml", acl_spec: true do 88 | let(:example_credentials) { x509_certificate_hash(ou: "ponycopter") } 89 | 90 | subject do 91 | Rails::Auth::ACL.from_yaml( 92 | File.read("/path/to/example_acl.yml"), 93 | matchers: { allow_x509_subject: Rails::Auth::X509::Matcher } 94 | ) 95 | end 96 | 97 | describe "/path/to/resource" do 98 | it { is_expected.to permit get_request(credentials: example_credentials) } 99 | it { is_expected.not_to permit get_request } 100 | end 101 | end 102 | ``` 103 | 104 | * Request builders: The following methods build requests from the described path: 105 | * `get_request` 106 | * `head_request` 107 | * `put_request` 108 | * `post_request` 109 | * `delete_request` 110 | * `options_request` 111 | * `path_request` 112 | * `link_request` 113 | * `unlink_request` 114 | 115 | The following matchers are available: 116 | 117 | * `allow_request`: allows a request with the given Rack environment, and optional credentials -------------------------------------------------------------------------------- /spec/rails/auth/x509/certificate_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rails::Auth::X509::Certificate do 4 | let(:example_cert) { OpenSSL::X509::Certificate.new(cert_path("valid.crt").read) } 5 | let(:example_cert_with_extension) { OpenSSL::X509::Certificate.new(cert_path("valid_with_ext.crt").read) } 6 | let(:example_certificate) { described_class.new(example_cert) } 7 | let(:example_certificate_with_extension) { described_class.new(example_cert_with_extension) } 8 | 9 | let(:example_cn) { "127.0.0.1" } 10 | let(:example_dns_names) { %w[example.com exemplar.com somethingelse.com] } 11 | let(:example_ips) { %w[0.0.0.0 127.0.0.1 192.168.1.1] } 12 | let(:example_ou) { "ponycopter" } 13 | let(:example_spiffe) { "spiffe://example.com/exemplar" } 14 | let(:example_uris) { [example_spiffe, "https://www.example.com/page1", "https://www.example.com/page2"] } 15 | 16 | describe "without extensions" do 17 | describe "#[]" do 18 | it "allows access to subject components via strings" do 19 | expect(example_certificate["CN"]).to eq example_cn 20 | expect(example_certificate["OU"]).to eq example_ou 21 | end 22 | 23 | it "allows access to subject components via symbols" do 24 | expect(example_certificate[:cn]).to eq example_cn 25 | expect(example_certificate[:ou]).to eq example_ou 26 | end 27 | end 28 | 29 | it "knows its #cn" do 30 | expect(example_certificate.cn).to eq example_cn 31 | end 32 | 33 | it "has no #dns_names" do 34 | expect(example_certificate.dns_names).to be_empty 35 | end 36 | 37 | it "has no #ips" do 38 | expect(example_certificate.ips).to be_empty 39 | end 40 | 41 | it "knows its #ou" do 42 | expect(example_certificate.ou).to eq example_ou 43 | end 44 | 45 | it "has no #uris" do 46 | expect(example_certificate.uris).to be_empty 47 | end 48 | 49 | it "has no #spiffe_id" do 50 | expect(example_certificate.spiffe_id).to be_nil 51 | end 52 | 53 | it "knows its attributes" do 54 | expect(example_certificate.attributes).to eq(cn: example_cn, ou: example_ou) 55 | end 56 | 57 | it "compares certificate objects by comparing their certificates" do 58 | second_cert = OpenSSL::X509::Certificate.new(cert_path("valid.crt").read) 59 | second_certificate = described_class.new(second_cert) 60 | 61 | expect(example_certificate).to be_eql second_certificate 62 | end 63 | end 64 | 65 | describe "with extensions" do 66 | describe "#[]" do 67 | it "allows access to subject components via strings" do 68 | expect(example_certificate_with_extension["CN"]).to eq example_cn 69 | expect(example_certificate_with_extension["OU"]).to eq example_ou 70 | end 71 | 72 | it "allows access to subject components via symbols" do 73 | expect(example_certificate_with_extension[:cn]).to eq example_cn 74 | expect(example_certificate_with_extension[:ou]).to eq example_ou 75 | end 76 | end 77 | 78 | it "knows its #cn" do 79 | expect(example_certificate_with_extension.cn).to eq example_cn 80 | end 81 | 82 | it "knows its #dns_names" do 83 | expect(example_certificate_with_extension.dns_names).to eq example_dns_names 84 | end 85 | 86 | it "knows its #ips" do 87 | expect(example_certificate_with_extension.ips).to eq example_ips 88 | end 89 | 90 | it "knows its #ou" do 91 | expect(example_certificate_with_extension.ou).to eq example_ou 92 | end 93 | 94 | it "knows its #spiffe_id" do 95 | expect(example_certificate_with_extension.spiffe_id).to eq example_spiffe 96 | end 97 | 98 | it "knows its #uris" do 99 | expect(example_certificate_with_extension.uris).to eq example_uris 100 | end 101 | 102 | it "knows its attributes" do 103 | expected_attrs = { 104 | cn: example_cn, 105 | dns_names: example_dns_names, 106 | ips: example_ips, 107 | ou: example_ou, 108 | spiffe_id: example_spiffe, 109 | uris: example_uris 110 | } 111 | expect(example_certificate_with_extension.attributes).to eq(expected_attrs) 112 | end 113 | 114 | it "compares certificate objects by comparing their certificates" do 115 | second_cert = OpenSSL::X509::Certificate.new(cert_path("valid_with_ext.crt").read) 116 | second_certificate = described_class.new(second_cert) 117 | 118 | expect(example_certificate_with_extension).to be_eql second_certificate 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /spec/rails/auth/acl/resource_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rails::Auth::ACL::Resource do 4 | let(:example_method) { "GET" } 5 | let(:another_method) { "POST" } 6 | let(:example_path) { "/foobar" } 7 | let(:another_path) { "/baz" } 8 | 9 | let(:example_matchers) { { "example" => double(:matcher, match: matcher_matches) } } 10 | let(:example_resource) { described_class.new(example_options, example_matchers) } 11 | let(:example_env) { env_for(example_method, example_path) } 12 | 13 | describe "#initialize" do 14 | it "initializes with a method and a path" do 15 | resource = described_class.new( 16 | { 17 | "method" => example_method, 18 | "path" => example_path 19 | }, 20 | {} 21 | ) 22 | 23 | expect(resource.http_methods).to eq [example_method] 24 | end 25 | 26 | it "accepts ALL as a specifier for all HTTP methods" do 27 | resource = described_class.new( 28 | { 29 | "method" => "ALL", 30 | "path" => example_path 31 | }, 32 | {} 33 | ) 34 | 35 | expect(resource.http_methods).to eq Rails::Auth::ACL::Resource::HTTP_METHODS 36 | end 37 | 38 | context "errors" do 39 | let(:invalid_method) { "DERP" } 40 | 41 | it "raises ParseError for invalid HTTP methods" do 42 | expect do 43 | described_class.new( 44 | { 45 | "method" => invalid_method, 46 | "path" => example_path 47 | }, 48 | {} 49 | ) 50 | end.to raise_error(Rails::Auth::ParseError) 51 | end 52 | end 53 | end 54 | 55 | context "without a host specified" do 56 | let(:example_options) do 57 | { 58 | "method" => example_method, 59 | "path" => example_path 60 | } 61 | end 62 | 63 | describe "#match" do 64 | context "with matching matchers and method/path" do 65 | let(:matcher_matches) { true } 66 | 67 | it "matches against a valid resource" do 68 | expect(example_resource.match(example_env)).to eq "example" 69 | end 70 | end 71 | 72 | context "without matching matchers" do 73 | let(:matcher_matches) { false } 74 | 75 | it "doesn't match against a valid resource" do 76 | expect(example_resource.match(example_env)).to eq nil 77 | end 78 | end 79 | 80 | context "without a method/path match" do 81 | let(:matcher_matches) { true } 82 | 83 | it "doesn't match" do 84 | env = env_for(another_method, example_path) 85 | expect(example_resource.match(env)).to eq nil 86 | end 87 | end 88 | end 89 | 90 | describe "#match!" do 91 | let(:matcher_matches) { false } 92 | 93 | it "matches against all methods if specified" do 94 | resource = described_class.new(example_options.merge("method" => "ALL"), example_matchers) 95 | expect(resource.match!(example_env)).to eq true 96 | end 97 | 98 | it "doesn't match if the method mismatches" do 99 | env = env_for(another_method, example_path) 100 | expect(example_resource.match!(env)).to eq false 101 | end 102 | 103 | it "doesn't match if the path mismatches" do 104 | env = env_for(example_method, another_path) 105 | expect(example_resource.match!(env)).to eq false 106 | end 107 | end 108 | end 109 | 110 | context "with a host specified" do 111 | let(:example_host) { "www.example.com" } 112 | let(:bogus_host) { "www.trololol.com" } 113 | let(:matcher_matches) { true } 114 | 115 | let(:example_options) do 116 | { 117 | "method" => example_method, 118 | "path" => example_path, 119 | "host" => example_host 120 | } 121 | end 122 | 123 | describe "#match" do 124 | it "matches if the host matches" do 125 | example_env["HTTP_HOST"] = example_host 126 | expect(example_resource.match(example_env)).to eq "example" 127 | end 128 | 129 | it "doesn't match if the host mismatches" do 130 | example_env["HTTP_HOST"] = bogus_host 131 | expect(example_resource.match(example_env)).to eq nil 132 | end 133 | end 134 | 135 | describe "#match!" do 136 | it "matches if the host matches" do 137 | example_env["HTTP_HOST"] = example_host 138 | expect(example_resource.match(example_env)).to eq "example" 139 | end 140 | 141 | it "doesn't match if the host mismatches" do 142 | example_env["HTTP_HOST"] = bogus_host 143 | expect(example_resource.match(example_env)).to eq nil 144 | end 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /docs/Rails-Usage.md: -------------------------------------------------------------------------------- 1 | ## Gemfile 2 | 3 | Add the following to your Rails app's Gemfile: 4 | 5 | ```ruby 6 | gem "rails-auth" 7 | ``` 8 | 9 | Then run: 10 | 11 | ``` 12 | $ bundle 13 | ``` 14 | 15 | You should see the `rails-auth` gem be added to your app. 16 | 17 | ## Configuration 18 | 19 | The `Rails::Auth::ConfigBuilder` module contains methods to configure Rails::Auth for various environments. We'll be adding it to `config/application.rb` and environment-specific configs. We'll also need to create an ACL file: 20 | 21 | ### config/acl.yml 22 | 23 | This file contains our app's [[Access Control List|Access Control Lists]]. Here is a starter ACL you can use that allows access to `/` and `/assets`: 24 | 25 | ```yaml 26 | --- 27 | - resources: 28 | - path: "/" 29 | method: GET 30 | - path: "/assets/.*" 31 | method: GET 32 | allow_all: true 33 | ``` 34 | 35 | Each time you add new routes to your app, you will have to add them to your ACL and decide how they should be authorized. See [[Access Control Lists]] for more information. 36 | 37 | ### config/application.rb 38 | 39 | ```ruby 40 | module MyApp 41 | class Application < Rails::Application 42 | [...] 43 | 44 | Rails::Auth::ConfigBuilder.application(config, matchers: { allow_x509_subject: Rails::Auth::X509::Matcher }) 45 | end 46 | end 47 | ``` 48 | 49 | The `application` method accepts the following options: 50 | 51 | * **acl_file:** path to the [[Access Control List|Access Control Lists]] file for this app. Defaults to `config/acl.yml`. 52 | * **matchers:** a set of ACL [[matchers]] to use. The above example configures [[X.509]] matchers only. 53 | 54 | ### config/environments/development.rb 55 | 56 | ```ruby 57 | Rails.application.configure do 58 | # Settings specified here will take precedence over those in config/application.rb. 59 | 60 | Rails::Auth::ConfigBuilder.development(config) 61 | end 62 | ``` 63 | 64 | The `development` method accepts the following options: 65 | 66 | * **development_credentials:** a hash of simulated credentials to use in the development environment. 67 | * **error_page:** (defaults to `:debug`) enables [[error page middleware|error handling]] for handling AuthZ failures. 68 | 69 | ### config/environments/test.rb 70 | 71 | ```ruby 72 | Rails.application.configure do 73 | # Settings specified here will take precedence over those in config/application.rb. 74 | 75 | Rails::Auth::ConfigBuilder.test(config) 76 | end 77 | ``` 78 | 79 | This middleware takes no options, but configures `Rails.configuration.x.rails_auth.test_credentials` to be injected into the request during tests. This is needed for the [[RSpec Support]]'s `with_credentials` helper method to function correctly. 80 | 81 | ### config/environments/production.rb 82 | 83 | ```ruby 84 | Rails.application.configure do 85 | # Settings specified here will take precedence over those in config/application.rb. 86 | 87 | Rails::Auth::ConfigBuilder.production( 88 | config, 89 | cert_filters: { "X-SSL-Client-Cert" => :pem }, 90 | ca_file: "/path/to/your/internal/ca.pem" 91 | ) 92 | end 93 | ``` 94 | 95 | The `production` method accepts the following options: 96 | 97 | * **cert_filters**: A `Hash` which configures how client certificates are extracted from the Rack environment. You will need to configure your web server to include the certificate in the Rack environment. See notes below for more details. 98 | * **ca_file**: Path to the certificate authority (CA) bundle with which to authenticate clients. This will typically be the certificates for the internal CA(s) you use to issue [[X.509]] certificates to internal services, as opposed to commercial CAs typically used by browsers. Client certificates will be ignored unless they can be verified by one of the CAs in this bundle. 99 | * **require_cert**: (default `false`) require a valid client cert in order for the request to complete. This disallows access to your app from any clients who do not have a valid client certificate. When enabled, the middleware will raise the `Rails::Auth::X509::CertificateVerifyFailed` exception. 100 | * **error_page:** (defaults to `public/403.html`) renders a static [[error page|error handling]] in the event of an authorization failure. Takes a `Pathname` or `String` path to a static file to render, `:debug` to enable a rich access debugger, or `false` to disable the error page and let the exception bubble up. 101 | * **monitor:** a [[monitor]] proc which is called with the Rack environment and whether or not authorization was successful. Useful for logging and/or for reporting AuthZ failures to an internal monitoring system. 102 | 103 | For [[X.509]] client certificate-based authentication to work, you will need to configure your web server to include them in your Rack environment, and also configure `cert_filters` correctly to filter and process them from the Rack environment. Please see the [[X.509]] page for more information on how to configure `cert_filters`. 104 | 105 | ## Controller Methods 106 | 107 | Rails::Auth includes a module of helper methods you can use from Rails controllers. Include them like so: 108 | 109 | ```ruby 110 | class ApplicationController < ActionController::Base 111 | # Include this in your ApplicationController 112 | include Rails::Auth::ControllerMethods 113 | end 114 | ``` 115 | 116 | This defines the following methods: 117 | 118 | * `#credentials`: obtain a `HashWithIndifferentAccess` containing all of the credentials that Rails::Auth has extracted using its AuthN middleware. 119 | 120 | Below is a larger example of how you can use the `credentials` method in your app: 121 | 122 | ```ruby 123 | class ApplicationController < ActionController::Base 124 | # Include this in your ApplicationController 125 | include Rails::Auth::ControllerMethods 126 | 127 | def x509_certificate_ou 128 | credentials[:x509].try(:ou) 129 | end 130 | 131 | def current_username 132 | # Note: Rails::Auth doesn't provide a middleware to extract this, it's 133 | # just an example of how you could use it with your own claims-based 134 | # identity system. 135 | credentials[:identity_claims].try(:username) 136 | end 137 | end 138 | ``` -------------------------------------------------------------------------------- /docs/Rack-Usage.md: -------------------------------------------------------------------------------- 1 | Rails::Auth, despite the name, includes a Rack-only mode which is not dependent on Rails: 2 | 3 | ```ruby 4 | require "rails/auth/rack" 5 | ``` 6 | 7 | To use Rails::Auth you will need to configure the relevant AuthN and AuthZ middleware for your app. 8 | 9 | Rails::Auth ships with the following middleware: 10 | 11 | * **AuthN**: `Rails::Auth::X509::Middleware`: support for authenticating clients by their SSL/TLS client certificates. Please see [[X.509]] for more information. 12 | * **AuthZ**: `Rails::Auth::ACL::Middleware`: support for authorizing requests using [[Access Control Lists]] (ACLs). 13 | 14 | ## ACL Middleware 15 | 16 | Once you've defined an [[Access Control List|Access Control Lists]], you'll need to create a corresponding ACL object in Ruby and a middleware to authorize requests using that ACL. Add the following code anywhere you can modify the middleware chain (e.g. config.ru): 17 | 18 | ```ruby 19 | app = MyRackApp.new 20 | 21 | acl = Rails::Auth::ACL.from_yaml( 22 | File.read("/path/to/my/acl.yaml"), 23 | matchers: { allow_claims: MyClaimsMatcher } 24 | ) 25 | 26 | acl_auth = Rails::Auth::ACL::Middleware.new(app, acl: acl) 27 | 28 | run acl_auth 29 | ``` 30 | 31 | You'll need to pass in a hash of predicate matchers that correspond to the keys in the ACL. 32 | 33 | ## X.509 Middleware 34 | 35 | Add an `Rails::Auth::X509::Middleware` object to your Rack middleware chain to verify [[X.509]] client certificates (in e.g. config.ru): 36 | 37 | ```ruby 38 | app = MyRackApp.new 39 | 40 | acl = Rails::Auth::ACL.from_yaml( 41 | File.read("/path/to/my/acl.yaml") 42 | matchers: { allow_x509_subject: Rails::Auth::X509::Matcher } 43 | ) 44 | 45 | acl_auth = Rails::Auth::ACL::Middleware.new(app, acl: acl) 46 | 47 | x509_auth = Rails::Auth::X509::Middleware.new( 48 | acl_auth, 49 | ca_file: "/path/to/my/cabundle.pem" 50 | cert_filters: { "X-SSL-Client-Cert" => :pem }, 51 | require_cert: true 52 | ) 53 | 54 | run x509_auth 55 | ``` 56 | 57 | The constructor takes the following parameters: 58 | 59 | * **app**: the next Rack middleware in the chain. You'll likely want to use an `Rails::Auth::ACL::Middleware` instance as the next middleware in the chain. 60 | * **ca_file**: Path to the certificate authority (CA) bundle with which to authenticate clients. This will typically be the certificates for the internal CA(s) you use to issue X.509 certificates to internal services, as opposed to commercial CAs typically used by browsers. Client certificates will be ignored unless they can be verified by one of the CAs in this bundle. 61 | * **cert_filters**: A `Hash` which configures how client certificates are extracted from the Rack environment. You will need to configure your web server to include the certificate in the Rack environment. See notes below for more details. 62 | * **require_cert**: (default `false`) require a valid client cert in order for the request to complete. This disallows access to your app from any clients who do not have a valid client certificate. When enabled, the middleware will raise the `Rails::Auth::X509::CertificateVerifyFailed` exception. 63 | 64 | When creating `Rails::Auth::ACL::Middleware`, make sure to pass in `matchers: { allow_x509_subject: Rails::Auth::X509::Matcher }` in order to use this predicate in your ACLs. This predicate matcher is not enabled by default. 65 | 66 | For client certs to work, you will need to configure your web server to include them in your Rack environment, and also configure `cert_filters` correctly to filter and process them from the Rack environment. For more information on configuring `cert_filters`, see the [[X.509]] Wiki page. 67 | 68 | ## Error Page Middleware 69 | 70 | When an authorization error occurs, the `Rails::Auth::NotAuthorizedError` exception is raised up the middleware chain. However, it's likely you would prefer to show an error page than have an unhandled exception. 71 | 72 | You can write your own middleware that catches `Rails::Auth::NotAuthorizedError` if you'd like. However, this library includes two middleware for rescuing this exception for you and displaying an error page. 73 | 74 | For more information, see the [[Error Handling]] Wiki page. 75 | 76 | #### Rails::Auth::ErrorPage::DebugMiddleware 77 | 78 | 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. 79 | 80 | ```ruby 81 | app = MyRackApp.new 82 | 83 | acl = Rails::Auth::ACL.from_yaml( 84 | File.read("/path/to/my/acl.yaml") 85 | matchers: { allow_x509_subject: Rails::Auth::X509::Matcher } 86 | ) 87 | 88 | acl_auth = Rails::Auth::ACL::Middleware.new(app, acl: acl) 89 | 90 | x509_auth = Rails::Auth::X509::Middleware.new( 91 | acl_auth, 92 | ca_file: "/path/to/my/cabundle.pem" 93 | cert_filters: { "X-SSL-Client-Cert" => :pem }, 94 | require_cert: true 95 | ) 96 | 97 | error_page = Rails::Auth::ErrorPage::DebugMiddleware.new(x509_auth, acl: acl) 98 | 99 | run error_page 100 | ``` 101 | 102 | #### Rails::Auth::ErrorPage::Middleware 103 | 104 | 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. 105 | 106 | ```ruby 107 | app = MyRackApp.new 108 | 109 | acl = Rails::Auth::ACL.from_yaml( 110 | File.read("/path/to/my/acl.yaml") 111 | matchers: { allow_x509_subject: Rails::Auth::X509::Matcher } 112 | ) 113 | 114 | acl_auth = Rails::Auth::ACL::Middleware.new(app, acl: acl) 115 | 116 | x509_auth = Rails::Auth::X509::Middleware.new( 117 | acl_auth, 118 | ca_file: "/path/to/my/cabundle.pem" 119 | cert_filters: { "X-SSL-Client-Cert" => :pem }, 120 | require_cert: true 121 | ) 122 | 123 | error_page = Rails::Auth::ErrorPage::Middleware.new( 124 | x509_auth, 125 | page_body: File.read("path/to/403.html") 126 | ) 127 | 128 | run error_page 129 | ``` 130 | 131 | ## Monitor Middleware 132 | 133 | `Rails::Auth::Monitor::Middleware` allows you to configure a user-specified callback which is fired each time an authorization decision is made: 134 | 135 | ```ruby 136 | app = MyRackApp.new 137 | 138 | acl = Rails::Auth::ACL.from_yaml( 139 | File.read("/path/to/my/acl.yaml"), 140 | matchers: { allow_claims: MyClaimsMatcher } 141 | ) 142 | 143 | acl_auth = Rails::Auth::ACL::Middleware.new(app, acl: acl) 144 | 145 | callback = lambda do |env, success| 146 | puts "AuthZ result for #{env["PATH_INFO"]}: #{success}" 147 | end 148 | 149 | monitor_middleware = Rails::Auth::Monitor::Middleware.new(acl_auth, callback) 150 | 151 | run monitor_middleware 152 | ``` -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ### 3.1.0 (2021-10-26) 2 | 3 | * [#70](https://github.com/square/rails-auth/pull/70) 4 | Support URL-encoded PEMs to support new Puma header requirements. 5 | ([@drcapulet]) 6 | 7 | ### 3.0.0 (2020-08-10) 8 | 9 | * [#68](https://github.com/square/rails-auth/pull/68) 10 | Remove `ca_file` and `require_cert` options to the config builder as we no 11 | longer verify the certificate chain. 12 | ([@drcapulet]) 13 | 14 | * [#67](https://github.com/square/rails-auth/pull/67) 15 | Remove `ca_file`, `require_cert`, and `truststore` options to X509 middleware 16 | as we no longer verify the certificate chain. 17 | ([@drcapulet]) 18 | 19 | ### 2.2.2 (2020-07-02) 20 | 21 | * [#65](https://github.com/square/rails-auth/pull/65) 22 | Fix error when passing `truststore` instead of `ca_file` to X509 middleware. 23 | ([@drcapulet]) 24 | 25 | ### 2.2.1 (2020-01-08) 26 | 27 | * [#63](https://github.com/square/rails-auth/pull/63) 28 | Fix `FrozenError` in `permit` matcher description. 29 | ([@drcapulet]) 30 | 31 | ### 2.2.0 (2019-12-05) 32 | 33 | * [#55](https://github.com/square/rails-auth/pull/55) 34 | Allow dynamic injection of credentials. 35 | ([@drcapulet]) 36 | 37 | * [#59](https://github.com/square/rails-auth/pull/59) 38 | Expose X.509 Subject Alternative Name extension 39 | in the Rails::Auth::X509::Certificate and provide a convenience 40 | method `spiffe_id` to expose [SPIFFE ID](https://spiffe.io). 41 | ([@mbyczkowski]) 42 | 43 | * [#57](https://github.com/square/rails-auth/pull/57) 44 | Add support for latest versions of Ruby, JRuby and Bundler 2. 45 | ([@mbyczkowski]) 46 | 47 | ### 2.1.4 (2018-07-12) 48 | 49 | * [#51](https://github.com/square/rails-auth/pull/51) 50 | Fix bug in `permit` custom matcher so that a description is rendered. 51 | ([@yellow-beard]) 52 | 53 | ### 2.1.3 (2017-08-04) 54 | 55 | * [#44](https://github.com/square/rails-auth/pull/44) 56 | Normalize abnormal whitespace in PEM certificates for Passenger 5. 57 | ([@drcapulet]) 58 | 59 | ### 2.1.2 (2017-01-27) 60 | 61 | * [#42](https://github.com/square/rails-auth/pull/42) 62 | Don't leak credentials between requests in test / development. 63 | ([@nerdrew]) 64 | 65 | ### 2.1.1 (2016-09-24) 66 | 67 | * [#41](https://github.com/square/rails-auth/pull/41) 68 | Fix Rails router constraint for checking rails-auth is installed. 69 | ([@drcapulet]) 70 | 71 | ### 2.1.0 (2016-09-24) 72 | 73 | * [#40](https://github.com/square/rails-auth/pull/40) 74 | Add Rails router constraint for checking rails-auth is installed. 75 | ([@drcapulet]) 76 | 77 | ### 2.0.3 (2016-07-20) 78 | 79 | * [#39](https://github.com/square/rails-auth/pull/39) 80 | Make credentials idempotent. 81 | ([@tarcieri]) 82 | 83 | * [#38](https://github.com/square/rails-auth/pull/38) 84 | Monitor callback must respond to :call. 85 | ([@tarcieri]) 86 | 87 | ### 2.0.2 (2016-07-19) 88 | 89 | * [#37](https://github.com/square/rails-auth/pull/37) 90 | Forward #each on Rails::Auth::Credentials and make 91 | it Enumerable. 92 | ([@tarcieri]) 93 | 94 | ### 2.0.1 (2016-07-16) 95 | 96 | * [#36](https://github.com/square/rails-auth/pull/36) 97 | Extract Rack environment manipulation into the 98 | Rails::Auth::Env class. 99 | ([@tarcieri]) 100 | 101 | * [#35](https://github.com/square/rails-auth/pull/35) 102 | Make allowed_by a mandatory argument of 103 | Rails::Auth.authorized! 104 | ([@tarcieri]) 105 | 106 | ### 2.0.0 (2016-07-16; yanked in favor of 2.0.1) 107 | 108 | * [#34](https://github.com/square/rails-auth/pull/34) 109 | Rails::Auth.allowed_by stores the matcher used to 110 | authorize the request in the Rack environment. 111 | ([@tarcieri]) 112 | 113 | * [#33](https://github.com/square/rails-auth/pull/33) 114 | Rails::Auth::Monitor::Middleware provides callbacks 115 | for authorization success/failure for logging or 116 | monitoring purposes. 117 | ([@tarcieri]) 118 | 119 | * [#32](https://github.com/square/rails-auth/pull/32) 120 | Rails::Auth::ConfigBuilder provides a simplified config 121 | API for Rails apps. 122 | ([@tarcieri]) 123 | 124 | ### 1.3.0 (2016-07-16) 125 | 126 | * [#30](https://github.com/square/rails-auth/pull/30) 127 | Render JSON error responses from Rails::Auth::ErrorPage. 128 | ([@tarcieri]) 129 | 130 | ### 1.2.0 (2016-07-11) 131 | 132 | * [#28](https://github.com/square/rails-auth/pull/28) 133 | Add a attr_reader for Rails::Auth::ACL#resources. 134 | ([@tarcieri]) 135 | 136 | * [#27](https://github.com/square/rails-auth/pull/27) 137 | Handle javax.servlet.request.X509Certificate arrays. 138 | ([@tarcieri]) 139 | 140 | ### 1.1.0 (2016-06-23) 141 | 142 | * [#26](https://github.com/square/rails-auth/pull/26) 143 | Make add_credential idempotent. 144 | ([@ewr]) 145 | 146 | * [#25](https://github.com/square/rails-auth/pull/25) 147 | Allow outside middleware to mark a request as authorized. 148 | ([@ewr]) 149 | 150 | ### 1.0.0 (2016-05-03) 151 | 152 | * Initial 1.0 release! 153 | 154 | ### 0.5.3 (2016-04-28) 155 | 156 | * [#22](https://github.com/square/rails-auth/pull/22) 157 | Use explicit HTTP_METHODS whitelist when 'ALL' method is used. 158 | ([@tarcieri]) 159 | 160 | ### 0.5.2 (2016-04-27) 161 | 162 | * [#21](https://github.com/square/rails-auth/pull/21) 163 | Send correct Content-Type on ErrorPage middleware. 164 | ([@tarcieri]) 165 | 166 | ### 0.5.1 (2016-04-24) 167 | 168 | * [#20](https://github.com/square/rails-auth/pull/20) 169 | Handle X5.09 filter exceptions. 170 | ([@tarcieri]) 171 | 172 | ### 0.5.0 (2016-04-24) 173 | 174 | * [#19](https://github.com/square/rails-auth/pull/19) 175 | Add Rails::Auth::Credentials::InjectorMiddleware. 176 | ([@tarcieri]) 177 | 178 | ### 0.4.1 (2016-04-23) 179 | 180 | * [#17](https://github.com/square/rails-auth/pull/17) 181 | Use PATH_INFO instead of REQUEST_PATH. 182 | ([@tarcieri]) 183 | 184 | * [#15](https://github.com/square/rails-auth/pull/15) 185 | Check types more thoroughly when parsing ACLs. 186 | ([@tarcieri]) 187 | 188 | ### 0.4.0 (2016-03-14) 189 | 190 | * [#14](https://github.com/square/rails-auth/pull/14) 191 | Support for optionally matching hostnames in ACL resources. 192 | ([@tarcieri]) 193 | 194 | * [#13](https://github.com/square/rails-auth/pull/13) 195 | Add #attributes method to matchers and X.509 certs. 196 | ([@tarcieri]) 197 | 198 | ### 0.3.0 (2016-03-12) 199 | 200 | * [#12](https://github.com/square/rails-auth/pull/12) 201 | Add Rails::Auth::ErrorPage::DebugMiddleware. 202 | ([@tarcieri]) 203 | 204 | ### 0.2.0 (2016-03-11) 205 | 206 | * [#10](https://github.com/square/rails-auth/pull/10) 207 | Add Rails::Auth::ControllerMethods and #credentials method for accessing 208 | rails-auth.credentials from a Rails controller. 209 | ([@tarcieri]) 210 | 211 | ### 0.1.0 (2016-02-10) 212 | 213 | * [#6](https://github.com/square/rails-auth/pull/6): 214 | Rename principals to credentials and Rails::Auth::X509::Principals to 215 | Rails::Auth::X509::Certificates. 216 | ([@tarcieri]) 217 | 218 | * [#5](https://github.com/square/rails-auth/pull/5): 219 | Add Rails::Auth::ErrorPage::Middleware. 220 | ([@tarcieri]) 221 | 222 | ### 0.0.1 (2016-01-26) 223 | 224 | * [#1](https://github.com/square/rails-auth/pull/1): 225 | Initial implementation. 226 | ([@tarcieri]) 227 | 228 | ### 0.0.0 (2016-01-04) 229 | 230 | * Vaporware release to claim the "rails-auth" gem name 231 | 232 | 233 | [@drcapulet]: https://github.com/drcapulet 234 | [@ewr]: https://github.com/ewr 235 | [@mbyczkowski]: https://github.com/mbyczkowski 236 | [@nerdrew]: https://github.com/nerdrew 237 | [@tarcieri]: https://github.com/tarcieri 238 | [@yellow-beard]: https://github.com/yellow-beard 239 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | --------------------------------------------------------------------------------