diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b5a8e2e..acaed7bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
#### Features
+* [#493](https://github.com/ruby-grape/grape-swagger/pull/493): Swagger UI endpoint authorization. - [@texpert](https://github.com/texpert).
* [#492](https://github.com/ruby-grape/grape/pull/492): Define security requirements on endpoint methods - [@tomregelink](https://github.com/tomregelink).
* [#497](https://github.com/ruby-grape/grape-swagger/pull/497): Use ruby-grape-danger in Dangerfile - [@dblock](https://github.com/dblock).
* Your contribution here.
diff --git a/README.md b/README.md
index 9e40c705..97c5440c 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@
* [Model Parsers](#model_parsers)
* [Configure](#configure)
* [Routes Configuration](#routes)
+* [Securing the Swagger UI](#oauth)
* [Markdown](#md_usage)
* [Response documentation](#response)
* [Extensions](#extensions)
@@ -192,6 +193,9 @@ end
* [add_version](#add_version)
* [doc_version](#doc_version)
* [markdown](#markdown)
+* [endpoint_auth_wrapper](#endpoint_auth_wrapper)
+* [swagger_endpoint_guard](#swagger_endpoint_guard)
+* [oauth_token](#oauth_token)
* [security_definitions](#security_definitions)
* [models](#models)
* [hide_documentation_path](#hide_documentation_path)
@@ -273,6 +277,33 @@ add_swagger_documentation \
markdown: GrapeSwagger::Markdown::RedcarpetAdapter.new
```
+
+#### endpoint_auth_wrapper:
+Specify the middleware to use for securing endpoints.
+
+```ruby
+add_swagger_documentation \
+ endpoint_auth_wrapper: WineBouncer::OAuth2
+```
+
+
+#### swagger_endpoint_guard:
+Specify the method and auth scopes, used by the middleware for securing endpoints.
+
+```ruby
+add_swagger_documentation \
+ swagger_endpoint_guard: 'oauth2 false'
+```
+
+
+#### oauth_token:
+Specify the method to get the oauth_token, provided by the middleware.
+
+```ruby
+add_swagger_documentation \
+ oauth_token: 'doorkeeper_access_token'
+```
+
#### security_definitions:
Specify the [Security Definitions Object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#security-definitions-object)
@@ -765,6 +796,75 @@ module API
end
```
+
+## Securing the Swagger UI
+
+
+The Swagger UI on Grape could be secured from unauthorized access using any middleware, which provides certain methods:
+
+- a *before* method to be run in the Grape controller for authorization purpose;
+- some guard method, which could receive as argument a string or an array of authorization scopes;
+- a method which processes and returns the access token received in the HTTP request headers (usually in the 'HTTP_AUTHORIZATION' header).
+
+Below are some examples of securing the Swagger UI on Grape installed along with Ruby on Rails:
+
+- The WineBouncer and Doorkeeper gems are used in the examples;
+- 'rails' and 'wine_bouncer' gems should be required prior to 'grape-swagger' in boot.rb;
+- This works with a fresh PR to WineBouncer which is yet unmerged - [WineBouncer PR](https://github.com/antek-drzewiecki/wine_bouncer/pull/64).
+
+This is how to configure the grape_swagger documentation:
+
+```ruby
+ add_swagger_documentation base_path: '/',
+ title: 'My API',
+ doc_version: '0.0.1',
+ hide_documentation_path: true,
+ hide_format: true,
+ endpoint_auth_wrapper: WineBouncer::OAuth2, # This is the middleware for securing the Swagger UI
+ swagger_endpoint_guard: 'oauth2 false', # this is the guard method and scope
+ oauth_token: 'doorkeeper_access_token' # This is the method returning the access_token
+```
+
+The guard method should inject the Security Requirement Object into the endpoint's route settings (see Grape::DSL::Settings.route_setting method).
+
+The 'oauth2 false' added to swagger_documentation is making the main Swagger endpoint protected with OAuth, i.e. it
+is retreiving the access_token from the HTTP request, but the 'false' scope is for skipping authorization and showing
+ the UI for everyone. If the scope would be set to something else, like 'oauth2 admin', for example, than the UI
+ wouldn't be displayed at all to unauthorized users.
+
+Further on, the guard could be used, where necessary, for endpoint access protection. Put it prior to the endpoint's method:
+
+```ruby
+ resource :users do
+ oauth2 'read, write'
+ get do
+ render_users
+ end
+
+ oauth2 'admin'
+ post do
+ User.create!...
+ end
+ end
+```
+
+And, finally, if you want to not only restrict the access, but to completely hide the endpoint from unauthorized
+users, you could pass a lambda to the :hidden key of a endpoint's description:
+
+```ruby
+ not_admins = lambda { |token=nil| token.nil? || !User.find(token.resource_owner_id).admin? }
+
+ resource :users do
+ desc 'Create user', hidden: not_admins
+ oauth2 'admin'
+ post do
+ User.create!...
+ end
+ end
+```
+
+The lambda is checking whether the user is authenticated (if not, the token is nil by default), and has the admin
+role - only admins can see this endpoint.
### Markdown in Detail
diff --git a/lib/grape-swagger.rb b/lib/grape-swagger.rb
index b1aea2d5..42f2edf3 100644
--- a/lib/grape-swagger.rb
+++ b/lib/grape-swagger.rb
@@ -31,6 +31,11 @@ def add_swagger_documentation(options = {})
version_for(options)
options = { target_class: self }.merge(options)
@target_class = options[:target_class]
+ auth_wrapper = options[:endpoint_auth_wrapper]
+
+ if auth_wrapper && auth_wrapper.method_defined?(:before) && !middleware.flatten.include?(auth_wrapper)
+ use auth_wrapper
+ end
documentation_class.setup(options)
mount(documentation_class)
diff --git a/lib/grape-swagger/doc_methods.rb b/lib/grape-swagger/doc_methods.rb
index 63dd0601..5713d55c 100644
--- a/lib/grape-swagger/doc_methods.rb
+++ b/lib/grape-swagger/doc_methods.rb
@@ -31,37 +31,38 @@ def setup(options)
# options could be set on #add_swagger_documentation call,
# for available options see #defaults
target_class = options[:target_class]
- api_doc = options[:api_documentation].dup
- specific_api_doc = options[:specific_api_documentation].dup
+ guard = options[:swagger_endpoint_guard]
+ formatter = options[:format]
class_variables_from(options)
[:format, :default_format, :default_error_formatter].each do |method|
- send(method, options[:format])
- end if options[:format]
- # getting of the whole swagger2.0 spec file
- desc api_doc.delete(:desc), api_doc
- get mount_path do
- header['Access-Control-Allow-Origin'] = '*'
- header['Access-Control-Request-Method'] = '*'
+ send(method, formatter)
+ end if formatter
- output = swagger_object(
+ send(guard.split.first.to_sym, *guard.split(/[\s,]+/).drop(1)) unless guard.nil?
+
+ output_path_definitions = proc do |combi_routes, endpoint|
+ output = endpoint.swagger_object(
target_class,
- request,
+ endpoint.request,
options
)
- target_routes = target_class.combined_namespace_routes
- paths, definitions = path_and_definition_objects(target_routes, options)
+ paths, definitions = endpoint.path_and_definition_objects(combi_routes, options)
output[:paths] = paths unless paths.blank?
output[:definitions] = definitions unless definitions.blank?
output
end
- # getting of a specific/named route of the swagger2.0 spec file
- desc specific_api_doc.delete(:desc), { params:
- specific_api_doc.delete(:params) || {} }.merge(specific_api_doc)
+ get mount_path do
+ header['Access-Control-Allow-Origin'] = '*'
+ header['Access-Control-Request-Method'] = '*'
+
+ output_path_definitions.call(target_class.combined_namespace_routes, self)
+ end
+
params do
requires :name, type: String, desc: 'Resource name of mounted API'
optional :locale, type: Symbol, desc: 'Locale of API documentation'
@@ -72,18 +73,7 @@ def setup(options)
combined_routes = target_class.combined_namespace_routes[params[:name]]
error!({ error: 'named resource not exist' }, 400) if combined_routes.nil?
- output = swagger_object(
- target_class,
- request,
- options
- )
-
- target_routes = { params[:name] => combined_routes }
- paths, definitions = path_and_definition_objects(target_routes, options)
- output[:paths] = paths unless paths.blank?
- output[:definitions] = definitions unless definitions.blank?
-
- output
+ output_path_definitions.call({ params[:name] => combined_routes }, self)
end
end
@@ -104,7 +94,10 @@ def defaults
authorizations: nil,
security_definitions: nil,
api_documentation: { desc: 'Swagger compatible API description' },
- specific_api_documentation: { desc: 'Swagger compatible API description for specific API' }
+ specific_api_documentation: { desc: 'Swagger compatible API description for specific API' },
+ endpoint_auth_wrapper: nil,
+ swagger_endpoint_guard: nil,
+ oauth_token: nil
}
end
diff --git a/lib/grape-swagger/endpoint.rb b/lib/grape-swagger/endpoint.rb
index b646a148..a3f338df 100644
--- a/lib/grape-swagger/endpoint.rb
+++ b/lib/grape-swagger/endpoint.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'active_support'
require 'active_support/core_ext/string/inflections.rb'
@@ -84,7 +86,7 @@ def add_definitions_from(models)
# path object
def path_item(routes, options)
routes.each do |route|
- next if hidden?(route)
+ next if hidden?(route, options)
@item, path = GrapeSwagger::DocMethods::PathString.build(route, options)
@entity = route.entity || route.options[:success]
@@ -287,10 +289,10 @@ def model_name(name)
name.respond_to?(:name) ? name.name.demodulize.camelize : name.split('::').last
end
- def hidden?(route)
+ def hidden?(route, options)
route_hidden = route.options[:hidden]
- route_hidden = route_hidden.call if route_hidden.is_a?(Proc)
- route_hidden
+ return route_hidden unless route_hidden.is_a?(Proc)
+ options[:oauth_token] ? route_hidden.call(send(options[:oauth_token].to_sym)) : route_hidden.call
end
def public_parameter?(param)
diff --git a/spec/swagger_v2/guarded_endpoint_spec.rb b/spec/swagger_v2/guarded_endpoint_spec.rb
new file mode 100644
index 00000000..d9dd7a79
--- /dev/null
+++ b/spec/swagger_v2/guarded_endpoint_spec.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+class SampleAuth < Grape::Middleware::Base
+ module AuthMethods
+ attr_accessor :access_token
+
+ def protected_endpoint=(protected)
+ @protected_endpoint = protected
+ end
+
+ def protected_endpoint?
+ @protected_endpoint || false
+ end
+
+ def access_token
+ @_access_token
+ end
+
+ def access_token=(token)
+ @_access_token = token
+ end
+ end
+
+ def context
+ env['api.endpoint']
+ end
+
+ def before
+ context.extend(SampleAuth::AuthMethods)
+ context.protected_endpoint = context.options[:route_options][:auth].present?
+
+ return unless context.protected_endpoint?
+ scopes = context.options[:route_options][:auth][:scopes].map(&:to_sym)
+ authorize!(*scopes) unless scopes.include? :false
+ context.access_token = env['HTTP_AUTHORIZATION']
+ end
+end
+
+module Extension
+ def sample_auth(*scopes)
+ description = route_setting(:description) || route_setting(:description, {})
+ description[:auth] = { scopes: scopes }
+ end
+
+ Grape::API.extend self
+end
+
+describe 'a guarded api endpoint' do
+ before :all do
+ class GuardedMountedApi < Grape::API
+ access_token_valid = proc { |token = nil| token.nil? || token != '12345' }
+
+ desc 'Show endpoint if authenticated', hidden: access_token_valid
+ get '/auth' do
+ { foo: 'bar' }
+ end
+ end
+
+ class GuardedApi < Grape::API
+ mount GuardedMountedApi
+ add_swagger_documentation endpoint_auth_wrapper: SampleAuth,
+ swagger_endpoint_guard: 'sample_auth false',
+ oauth_token: 'access_token'
+ end
+ end
+
+ def app
+ GuardedApi
+ end
+
+ context 'when a correct token is passed with the request' do
+ subject do
+ get '/swagger_doc.json', {}, 'HTTP_AUTHORIZATION' => '12345'
+ JSON.parse(last_response.body)
+ end
+
+ it 'retrieves swagger-documentation for the endpoint' do
+ expect(subject).to eq(
+ 'info' => { 'title' => 'API title', 'version' => '0.0.1' },
+ 'swagger' => '2.0',
+ 'produces' => ['application/xml', 'application/json', 'application/octet-stream', 'text/plain'],
+ 'host' => 'example.org',
+ 'paths' => {
+ '/auth' => {
+ 'get' => {
+ 'summary' => 'Show endpoint if authenticated',
+ 'description' => 'Show endpoint if authenticated',
+ 'produces' => ['application/json'],
+ 'tags' => ['auth'],
+ 'operationId' => 'getAuth',
+ 'responses' => { '200' => { 'description' => 'Show endpoint if authenticated' } }
+ }
+ }
+ }
+ )
+ end
+ end
+
+ context 'when a bad token is passed with the request' do
+ subject do
+ get '/swagger_doc.json', {}, 'HTTP_AUTHORIZATION' => '123456'
+ JSON.parse(last_response.body)
+ end
+
+ it 'does not retrieve swagger-documentation for the endpoint - only the info_object' do
+ expect(subject).to eq(
+ 'info' => { 'title' => 'API title', 'version' => '0.0.1' },
+ 'swagger' => '2.0',
+ 'produces' => ['application/xml', 'application/json', 'application/octet-stream', 'text/plain'],
+ 'host' => 'example.org'
+ )
+ end
+ end
+end