haveapi 0.31.2 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/benchmarks/documentation.rb +29 -6
- data/benchmarks/endpoints.rb +153 -24
- data/benchmarks/spec/client_examples_spec.rb +109 -37
- data/doc/client-examples.md +218 -45
- data/doc/documentation-oauth.md +157 -0
- data/doc/documentation-ui.md +192 -0
- data/doc/index.md +3 -6
- data/doc/performance.md +39 -12
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/authentication/chain.rb +19 -0
- data/lib/haveapi/authentication/oauth2/provider.rb +7 -1
- data/lib/haveapi/client_example.rb +143 -15
- data/lib/haveapi/client_examples/curl.rb +30 -20
- data/lib/haveapi/client_examples/dynamic_helpers.rb +1 -1
- data/lib/haveapi/client_examples/go_client.rb +9 -2
- data/lib/haveapi/client_examples/http.rb +9 -4
- data/lib/haveapi/client_examples/js_async_client.rb +7 -0
- data/lib/haveapi/client_examples/js_client.rb +47 -36
- data/lib/haveapi/client_examples/php_client.rb +161 -75
- data/lib/haveapi/client_examples/python_async_client.rb +3 -0
- data/lib/haveapi/client_examples/python_client.rb +61 -47
- data/lib/haveapi/client_examples/ruby_cli.rb +27 -18
- data/lib/haveapi/client_examples/ruby_client.rb +80 -31
- data/lib/haveapi/client_examples/rust_client.rb +20 -9
- data/lib/haveapi/client_examples/ts_async_client.rb +7 -0
- data/lib/haveapi/client_examples/ts_client.rb +3 -0
- data/lib/haveapi/client_examples/ts_generated_client.rb +10 -2
- data/lib/haveapi/documentation_context.rb +7 -2
- data/lib/haveapi/documentation_helpers.rb +223 -0
- data/lib/haveapi/documentation_oauth.rb +399 -0
- data/lib/haveapi/documentation_search.rb +225 -0
- data/lib/haveapi/documentation_search_index.rb +79 -0
- data/lib/haveapi/documentation_validators.rb +179 -0
- data/lib/haveapi/public/css/documentation.css +343 -0
- data/lib/haveapi/public/js/documentation.js +992 -0
- data/lib/haveapi/server.rb +144 -86
- data/lib/haveapi/shared_documentation_helpers.rb +54 -0
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/doc_layout.erb +16 -0
- data/lib/haveapi/views/doc_sidebars/create-client.erb +3 -3
- data/lib/haveapi/views/doc_sidebars/json-schema.erb +1 -1
- data/lib/haveapi/views/doc_sidebars/protocol.erb +5 -5
- data/lib/haveapi/views/documentation/search_form.erb +10 -0
- data/lib/haveapi/views/documentation/search_index.erb +1 -0
- data/lib/haveapi/views/documentation/search_results.erb +20 -0
- data/lib/haveapi/views/index.erb +2 -4
- data/lib/haveapi/views/main_layout.erb +12 -6
- data/lib/haveapi/views/shared_documentation.erb +12 -0
- data/lib/haveapi/views/shared_sidebar.erb +25 -0
- data/lib/haveapi/views/version_page/auth_body.erb +12 -33
- data/lib/haveapi/views/version_page/client_overview.erb +40 -0
- data/lib/haveapi/views/version_page/example_panels.erb +18 -0
- data/lib/haveapi/views/version_page/resource_body.erb +91 -132
- data/lib/haveapi/views/version_page/resource_directory.erb +14 -0
- data/lib/haveapi/views/version_page/usage.erb +7 -0
- data/lib/haveapi/views/version_page.erb +36 -117
- data/lib/haveapi/views/version_sidebar/auth_nav.erb +12 -16
- data/lib/haveapi/views/version_sidebar/auth_resource_nav.erb +19 -0
- data/lib/haveapi/views/version_sidebar/authentication_control.erb +26 -0
- data/lib/haveapi/views/version_sidebar/authentication_status.erb +3 -0
- data/lib/haveapi/views/version_sidebar/brand.erb +31 -0
- data/lib/haveapi/views/version_sidebar/resource_nav.erb +11 -20
- data/lib/haveapi/views/version_sidebar.erb +59 -48
- data/spec/authentication/basic_spec.rb +10 -10
- data/spec/authentication/oauth2_chain_spec.rb +72 -0
- data/spec/authentication/oauth2_token_validation_spec.rb +38 -0
- data/spec/client_examples/assets/documentation_test.cjs +817 -0
- data/spec/client_examples/context_performance_spec.rb +5 -4
- data/spec/client_examples/context_spec.rb +23 -2
- data/spec/client_examples/detail_spec.rb +175 -0
- data/spec/client_examples/dynamic_acceptance.md +59 -26
- data/spec/client_examples/dynamic_acceptance.rb +164 -44
- data/spec/client_examples/dynamic_acceptance_spec.rb +140 -23
- data/spec/client_examples/dynamic_fixture.rb +4 -3
- data/spec/client_examples/dynamic_js_spec.rb +20 -11
- data/spec/client_examples/dynamic_metadata_spec.rb +69 -0
- data/spec/client_examples/dynamic_mounted_paths_spec.rb +9 -7
- data/spec/client_examples/dynamic_python_spec.rb +62 -11
- data/spec/client_examples/go_spec.rb +14 -1
- data/spec/client_examples/presentation_spec.rb +247 -0
- data/spec/client_examples/profile_mount_spec.rb +40 -13
- data/spec/client_examples/rust_client_spec.rb +35 -1
- data/spec/client_examples/shell_spec.rb +268 -0
- data/spec/client_examples/syntax_spec.rb +192 -0
- data/spec/client_examples/ts_generated_client_spec.rb +15 -2
- data/spec/client_examples/typescript_profiles_spec.rb +1 -1
- data/spec/documentation/auth_filtering_spec.rb +9 -4
- data/spec/documentation/canonical_routes_spec.rb +89 -0
- data/spec/documentation/client_examples_spec.rb +39 -5
- data/spec/documentation/client_overview_spec.rb +145 -0
- data/spec/documentation/current_user_html_escaping_spec.rb +7 -2
- data/spec/documentation/example_variants_spec.rb +93 -0
- data/spec/documentation/fixtures/search_index.json +476 -0
- data/spec/documentation/identity_and_guides_spec.rb +130 -0
- data/spec/documentation/identity_context_spec.rb +180 -0
- data/spec/documentation/navigation_spec.rb +296 -0
- data/spec/documentation/oauth_fixture_spec.rb +39 -0
- data/spec/documentation/oauth_spec.rb +912 -0
- data/spec/documentation/oauth_transport_spec.rb +45 -0
- data/spec/documentation/performance_spec.rb +47 -6
- data/spec/documentation/reference_headings_spec.rb +190 -0
- data/spec/documentation/search_index_routes_spec.rb +202 -0
- data/spec/documentation/search_index_spec.rb +85 -0
- data/spec/documentation/search_routes_spec.rb +199 -0
- data/spec/documentation/search_spec.rb +134 -0
- data/spec/documentation/shared_pages_spec.rb +240 -0
- data/spec/documentation/validators_spec.rb +123 -0
- data/spec/documentation_spec.rb +3 -3
- data/spec/packaging_spec.rb +21 -2
- data/spec/performance/documentation_identity_fixture_spec.rb +47 -0
- data/spec/performance/documentation_shapes_spec.rb +97 -0
- data/spec/performance/json_activation_spec.rb +78 -1
- data/spec/performance/release_lineage_spec.rb +4 -3
- data/spec/performance/runner_spec.rb +1 -0
- data/test_support/all_client_performance_fixture.rb +6 -10
- data/test_support/documentation_oauth_fixture.rb +54 -0
- data/test_support/documentation_performance_fixture.rb +19 -1
- data/test_support/dynamic_documentation_api.rb +35 -10
- data/test_support/dynamic_python_login_spec.rb +3 -11
- metadata +52 -3
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'uri'
|
|
3
|
+
|
|
4
|
+
module HaveAPI
|
|
5
|
+
# HTML navigation is derived only from this request's authorized description.
|
|
6
|
+
module DocumentationHelpers
|
|
7
|
+
def documentation_identity_name
|
|
8
|
+
settings.api_server.documentation_name || request.host_with_port
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def documentation_document_title
|
|
12
|
+
page = @documentation_title || if @v
|
|
13
|
+
case @documentation_page
|
|
14
|
+
when 'authentication' then 'Authentication'
|
|
15
|
+
when 'resources' then 'Resources'
|
|
16
|
+
when 'resource' then @documentation_resource[:key]
|
|
17
|
+
else 'Overview'
|
|
18
|
+
end
|
|
19
|
+
else
|
|
20
|
+
'API versions'
|
|
21
|
+
end
|
|
22
|
+
version = @v && !@documentation_title ? "API v#{@v}" : nil
|
|
23
|
+
[page, version, documentation_identity_name].compact.join(' · ')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def documentation_account_context
|
|
27
|
+
return @documentation_account_context if defined?(@documentation_account_context)
|
|
28
|
+
|
|
29
|
+
context = if @v && @help
|
|
30
|
+
{ version: @v, user: current_user, authentication: @help[:authentication] }
|
|
31
|
+
else
|
|
32
|
+
@documentation_index_account
|
|
33
|
+
end
|
|
34
|
+
@documentation_account_context = context && context.merge(controls: documentation_oauth_controls)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def documentation_neutral_account_url
|
|
38
|
+
server = settings.api_server
|
|
39
|
+
return unless server.versions.length == 1 && server.default_version && documentation_oauth_controls
|
|
40
|
+
|
|
41
|
+
"#{url(server.version_prefix(server.default_version), false)}?page=authentication"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def documentation_authenticated?(version)
|
|
45
|
+
@current_user = nil
|
|
46
|
+
@current_user = documentation_authenticate(version)
|
|
47
|
+
settings.api_server.call_hooks_for(:post_authenticated, args: [@current_user])
|
|
48
|
+
resolve_request_locale(@current_user)
|
|
49
|
+
@current_user
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def documentation_oauth_controls
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def render_version_documentation(version)
|
|
57
|
+
headers 'Cache-Control' => 'private, no-store'
|
|
58
|
+
documentation_selection
|
|
59
|
+
documentation_search_query
|
|
60
|
+
@v = version
|
|
61
|
+
documentation_authenticated?(version)
|
|
62
|
+
@help = settings.api_server.describe_version(Context.new(
|
|
63
|
+
settings.api_server,
|
|
64
|
+
version:, user: current_user, params:
|
|
65
|
+
))
|
|
66
|
+
return render_documentation_search_index if documentation_search_index_request?
|
|
67
|
+
|
|
68
|
+
select_documentation_page
|
|
69
|
+
@documentation_ui = true
|
|
70
|
+
content_type 'text/html'
|
|
71
|
+
erb :doc_layout, layout: :main_layout do
|
|
72
|
+
@content = erb :version_page
|
|
73
|
+
@sidebar = erb :version_sidebar
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def documentation_selection?
|
|
78
|
+
documentation_search_index_request? || documentation_selection.any?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def documentation_selection
|
|
82
|
+
@documentation_selection ||= begin
|
|
83
|
+
query = request.query_string
|
|
84
|
+
halt 400, 'Invalid documentation selection' if query.match?(/%(?![0-9a-f]{2})/i)
|
|
85
|
+
pairs = URI.decode_www_form(query)
|
|
86
|
+
selected = pairs.select { |key, _| key.match?(/\A(?:page|resource)(?:\[|\z)/) }
|
|
87
|
+
unless selected.all? { |key, value| %w[page resource].include?(key) && value.valid_encoding? } &&
|
|
88
|
+
selected.map(&:first).uniq.length == selected.length && selected.length <= 1
|
|
89
|
+
halt 400, 'Invalid documentation selection'
|
|
90
|
+
end
|
|
91
|
+
selected.to_h
|
|
92
|
+
end
|
|
93
|
+
rescue ArgumentError
|
|
94
|
+
halt 400, 'Invalid documentation selection'
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def select_documentation_page
|
|
98
|
+
selection = documentation_selection
|
|
99
|
+
@documentation_page = selection.fetch('page', 'overview')
|
|
100
|
+
unless %w[overview authentication resources].include?(@documentation_page)
|
|
101
|
+
halt 400, 'Invalid documentation selection'
|
|
102
|
+
end
|
|
103
|
+
if @documentation_page == 'resources' && !documentation_search_query.empty?
|
|
104
|
+
@documentation_title = "Search API v#{@v}: #{documentation_search_query}"
|
|
105
|
+
end
|
|
106
|
+
return unless selection.has_key?('resource')
|
|
107
|
+
|
|
108
|
+
key = selection['resource']
|
|
109
|
+
unless key.match?(/\A[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*\z/)
|
|
110
|
+
halt 400, 'Invalid documentation selection'
|
|
111
|
+
end
|
|
112
|
+
@documentation_resource = documentation_resources.find { |entry| entry[:key] == key }
|
|
113
|
+
halt 404, 'Documentation resource not found' unless @documentation_resource
|
|
114
|
+
@documentation_page = 'resource'
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def documentation_resources
|
|
118
|
+
@documentation_resources ||= documentation_resource_entries(@help[:resources])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def documentation_resource_entries(resources, path = [])
|
|
122
|
+
sort_hash(resources).flat_map do |name, info|
|
|
123
|
+
resource_path = path + [name.to_s]
|
|
124
|
+
[{ key: resource_path.join('.'), path: resource_path, info:,
|
|
125
|
+
id: "root-#{resource_path.join('-')}", label: resource_path.map(&:humanize).join(' / ') }] +
|
|
126
|
+
documentation_resource_entries(info[:resources], resource_path)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def documentation_url(page: nil, resource: nil, anchor: nil)
|
|
131
|
+
query = if resource
|
|
132
|
+
"?resource=#{CGI.escape(Array(resource).join('.'))}"
|
|
133
|
+
elsif page && page != 'overview'
|
|
134
|
+
"?page=#{CGI.escape(page)}"
|
|
135
|
+
else
|
|
136
|
+
''
|
|
137
|
+
end
|
|
138
|
+
fragment = anchor ? "##{anchor}" : ''
|
|
139
|
+
"#{url(settings.api_server.version_prefix(@v), false)}#{query}#{fragment}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def documentation_reference_anchor(section:, resource_path: [], action_name: nil, authentication: nil, example_index: nil)
|
|
143
|
+
tuple = [authentication&.to_s, resource_path.map(&:to_s), action_name&.to_s, section.to_s, example_index]
|
|
144
|
+
"reference-#{JSON.generate(tuple).encode(Encoding::UTF_8).unpack1('H*')}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def documentation_resource_target(resource_path:, action_name: nil, authentication: nil)
|
|
148
|
+
if authentication
|
|
149
|
+
documentation_reference_anchor(resource_path:, action_name:, authentication:, section: action_name ? :action : :resource)
|
|
150
|
+
else
|
|
151
|
+
"root-#{resource_path.join('-')}#{"-#{action_name}" if action_name}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def documentation_action_sections(info, resource_path:, action_name:, authentication: nil)
|
|
156
|
+
sections = { input: 'Input parameters', output: 'Output parameters' }
|
|
157
|
+
sections[:examples] = 'Examples' unless info[:examples].empty?
|
|
158
|
+
sections.map do |section, label|
|
|
159
|
+
{ id: documentation_reference_anchor(resource_path:, action_name:, authentication:, section:), label: }
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def documentation_reference_alias(id)
|
|
164
|
+
@documentation_reference_aliases ||= {}
|
|
165
|
+
return '' if @documentation_reference_aliases[id]
|
|
166
|
+
|
|
167
|
+
@documentation_reference_aliases[id] = true
|
|
168
|
+
"<span class=\"docs-legacy-anchor\" id=\"#{escape_html(id)}\"></span>"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def documentation_permalink(id, label)
|
|
172
|
+
"<a class=\"docs-permalink\" href=\"##{escape_html(id)}\" aria-label=\"#{escape_html("Link to #{label}")}\"><span aria-hidden=\"true\">#</span></a>"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def documentation_client_metadata
|
|
176
|
+
documentation_clients.map do |client|
|
|
177
|
+
{ id: client.id, language: client.language, language_label: client.language_label, variant: client.variant,
|
|
178
|
+
details: (@documentation_page_details || {}).fetch(client.id, [:simple]).map(&:to_s) }
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def documentation_json(value)
|
|
183
|
+
JSON.generate(value).gsub('<', '\\u003c').gsub('>', '\\u003e').gsub('&', '\\u0026')
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def documentation_legacy_anchors
|
|
187
|
+
anchors = { 'api' => documentation_url, 'resources' => documentation_url(page: 'resources'),
|
|
188
|
+
'auth' => documentation_url(page: 'authentication') }
|
|
189
|
+
documentation_resources.each do |entry|
|
|
190
|
+
destination = documentation_url(resource: entry[:key])
|
|
191
|
+
anchors[entry[:id]] = destination
|
|
192
|
+
entry[:info][:actions].each do |action, info|
|
|
193
|
+
anchors["#{entry[:id]}-#{action}"] = destination
|
|
194
|
+
anchors["example-#{entry[:key]}-#{action}-"] = destination unless info[:examples].empty?
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
@help[:authentication].each do |name, info|
|
|
198
|
+
destination = documentation_url(page: 'authentication')
|
|
199
|
+
anchors["auth-#{name}"] = destination
|
|
200
|
+
anchors["auth-#{name}-examples"] = destination
|
|
201
|
+
documentation_clients.each do |client|
|
|
202
|
+
anchors["auth-#{name}-#{client.id}"] = destination
|
|
203
|
+
end
|
|
204
|
+
next unless info[:resources]
|
|
205
|
+
|
|
206
|
+
documentation_resource_entries(info[:resources]).each do |entry|
|
|
207
|
+
anchors[entry[:id]] ||= destination
|
|
208
|
+
entry[:info][:actions].each do |action, action_info|
|
|
209
|
+
anchors["#{entry[:id]}-#{action}"] ||= destination
|
|
210
|
+
anchors["example-#{entry[:key]}-#{action}-"] ||= destination unless action_info[:examples].empty?
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
anchors
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
private
|
|
218
|
+
|
|
219
|
+
def documentation_authenticate(_version)
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
require 'active_support/key_generator'
|
|
2
|
+
require 'active_support/message_encryptor'
|
|
3
|
+
require 'active_support/security_utils'
|
|
4
|
+
require 'base64'
|
|
5
|
+
require 'cgi'
|
|
6
|
+
require 'digest'
|
|
7
|
+
require 'json'
|
|
8
|
+
require 'net/http'
|
|
9
|
+
require 'openssl'
|
|
10
|
+
require 'securerandom'
|
|
11
|
+
require 'uri'
|
|
12
|
+
|
|
13
|
+
module HaveAPI
|
|
14
|
+
# Optional confidential OAuth client used only by the HTML documentation.
|
|
15
|
+
class DocumentationOAuth
|
|
16
|
+
class Invalid < ArgumentError; end
|
|
17
|
+
class Failure < StandardError; end
|
|
18
|
+
|
|
19
|
+
COOKIE_LIMIT = 3800
|
|
20
|
+
RESPONSE_LIMIT = 16_384
|
|
21
|
+
PENDING_LIFETIME = 600
|
|
22
|
+
FIELDS = %i[client_id client_secret redirect_uri session_secret scope session_lifetime switch_user].freeze
|
|
23
|
+
|
|
24
|
+
class Transport
|
|
25
|
+
def post(uri, parameters)
|
|
26
|
+
request = Net::HTTP::Post.new(uri)
|
|
27
|
+
request.set_form_data(parameters)
|
|
28
|
+
body = +''
|
|
29
|
+
status = nil
|
|
30
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true,
|
|
31
|
+
verify_mode: OpenSSL::SSL::VERIFY_PEER,
|
|
32
|
+
open_timeout: 5, read_timeout: 10, write_timeout: 10) do |http|
|
|
33
|
+
http.max_retries = 0
|
|
34
|
+
http.request(request) do |response|
|
|
35
|
+
status = response.code.to_i
|
|
36
|
+
response.read_body do |chunk|
|
|
37
|
+
raise Failure, 'Documentation OAuth request failed' if body.bytesize + chunk.bytesize > RESPONSE_LIMIT
|
|
38
|
+
|
|
39
|
+
body << chunk
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
raise Failure, 'Documentation OAuth request failed' unless status&.between?(200, 299)
|
|
44
|
+
|
|
45
|
+
body
|
|
46
|
+
rescue IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, Net::HTTPError, Net::HTTPBadResponse
|
|
47
|
+
raise Failure, 'Documentation OAuth request failed'
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
module Helpers
|
|
52
|
+
def documentation_oauth_controls
|
|
53
|
+
client = settings.api_server.documentation_oauth
|
|
54
|
+
return unless client
|
|
55
|
+
|
|
56
|
+
client.controls(self, @v || settings.api_server.default_version)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def documentation_authenticate(version)
|
|
62
|
+
client = settings.api_server.documentation_oauth
|
|
63
|
+
client ? client.authenticate(self, version) : super
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def initialize(**options)
|
|
68
|
+
raise Invalid, 'Unknown documentation OAuth configuration' unless (options.keys - FIELDS).empty?
|
|
69
|
+
|
|
70
|
+
@client_id = scalar(options[:client_id], 256)
|
|
71
|
+
@client_secret = scalar(options[:client_secret], 2048)
|
|
72
|
+
@redirect_uri = endpoint(options[:redirect_uri], query: false)
|
|
73
|
+
secret = options[:session_secret]
|
|
74
|
+
unless secret.is_a?(String) && secret.bytesize.between?(64, 4096)
|
|
75
|
+
raise Invalid, 'Documentation OAuth requires a session secret of at least 64 bytes'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@scope = options.fetch(:scope, [])
|
|
79
|
+
unless @scope.is_a?(Array) && @scope.length <= 32 && @scope.all? do |value|
|
|
80
|
+
value.is_a?(String) && value.bytesize.between?(1, 256) && value.match?(/\A[\x21\x23-\x5b\x5d-\x7e]+\z/)
|
|
81
|
+
end
|
|
82
|
+
raise Invalid, 'Invalid documentation OAuth scope'
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
@scope = @scope.map { |value| value.dup.freeze }.freeze
|
|
86
|
+
@lifetime = options.fetch(:session_lifetime, 3600)
|
|
87
|
+
unless @lifetime.is_a?(Integer) && @lifetime.between?(1, 86_400)
|
|
88
|
+
raise Invalid, 'Invalid documentation OAuth session lifetime'
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@switch_user = options.fetch(:switch_user, false)
|
|
92
|
+
unless @switch_user.equal?(true) || @switch_user.equal?(false)
|
|
93
|
+
raise Invalid, 'Invalid documentation OAuth switch-user capability'
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
@origin = "#{@redirect_uri.scheme}://#{@redirect_uri.host}"
|
|
97
|
+
@origin += ":#{@redirect_uri.port}" unless @redirect_uri.port == 443
|
|
98
|
+
key = ActiveSupport::KeyGenerator.new(secret, iterations: 65_536)
|
|
99
|
+
.generate_key('haveapi.documentation-oauth.v1', 32)
|
|
100
|
+
@encryptor = ActiveSupport::MessageEncryptor.new(key, cipher: 'aes-256-gcm', serializer: JSON)
|
|
101
|
+
@transport = Transport.new
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def inspect
|
|
105
|
+
'#<HaveAPI::DocumentationOAuth configured>'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def mount(server, sinatra)
|
|
109
|
+
raise Invalid, 'Documentation OAuth is already mounted' if @server
|
|
110
|
+
|
|
111
|
+
@server = server
|
|
112
|
+
@path = server.root.end_with?('/') ? server.root : "#{server.root}/"
|
|
113
|
+
@routes = %w[login callback logout].to_h { |name| [name, "#{@path}_documentation/oauth/#{name}"] }.freeze
|
|
114
|
+
unless @redirect_uri.path == @routes.fetch('callback')
|
|
115
|
+
raise Invalid, 'Documentation OAuth callback does not match the API mount'
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
metadata = server.auth_chain.provider_descriptions(server.default_version)[:oauth2]
|
|
119
|
+
raise Invalid, 'Documentation OAuth requires an OAuth provider' unless metadata.is_a?(Hash)
|
|
120
|
+
|
|
121
|
+
@endpoints = %i[authorize token revoke].to_h do |name|
|
|
122
|
+
[name, endpoint(metadata[:"#{name}_url"])]
|
|
123
|
+
end.freeze
|
|
124
|
+
binding = Digest::SHA256.hexdigest([@client_id, @redirect_uri.to_s, @path].join("\0"))
|
|
125
|
+
@purposes = %w[pending session].to_h { |kind| [kind, "haveapi.docs.#{binding}.#{kind}"] }.freeze
|
|
126
|
+
@names = %w[pending session].to_h { |kind| [kind, "haveapi_docs_#{Digest::SHA256.hexdigest(@path)[0, 12]}_#{kind}"] }.freeze
|
|
127
|
+
that = self
|
|
128
|
+
sinatra.get(@routes.fetch('login')) { that.login(self) }
|
|
129
|
+
sinatra.get(@routes.fetch('callback')) { that.callback(self) }
|
|
130
|
+
sinatra.post(@routes.fetch('logout')) { that.logout(self) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def authenticate(handler, version)
|
|
134
|
+
handler.instance_variable_set(:@documentation_oauth_csrf, nil)
|
|
135
|
+
protect_response(handler, referrer_policy: 'strict-origin')
|
|
136
|
+
return unless documentation_origin?(handler) && (handler.request.get? || handler.request.head?)
|
|
137
|
+
|
|
138
|
+
session = read_cookie(handler, 'session')
|
|
139
|
+
return unless session
|
|
140
|
+
|
|
141
|
+
user = @server.auth_chain.authenticate_oauth2_token(version, handler.request, session.fetch('token'))
|
|
142
|
+
if user
|
|
143
|
+
handler.instance_variable_set(:@documentation_oauth_csrf, session.fetch('csrf'))
|
|
144
|
+
else
|
|
145
|
+
clear_cookie(handler, 'session')
|
|
146
|
+
handler.instance_variable_set(:@documentation_oauth_message, 'Your documentation session ended. Sign in again.')
|
|
147
|
+
end
|
|
148
|
+
user
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def controls(handler, version)
|
|
152
|
+
selection = handler.documentation_selection
|
|
153
|
+
destination = return_location('version' => version.to_s, **selection)
|
|
154
|
+
csrf = handler.instance_variable_get(:@documentation_oauth_csrf)
|
|
155
|
+
controls = {
|
|
156
|
+
signed_in: !csrf.nil?, csrf_token: csrf,
|
|
157
|
+
login_url: "#{@origin}#{@routes.fetch('login')}?#{URI.encode_www_form(destination)}",
|
|
158
|
+
logout_url: "#{@origin}#{@routes.fetch('logout')}",
|
|
159
|
+
message: handler.instance_variable_get(:@documentation_oauth_message)
|
|
160
|
+
}
|
|
161
|
+
controls[:switch_user] = true if @switch_user && csrf
|
|
162
|
+
controls
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def login(handler)
|
|
166
|
+
protect_response(handler)
|
|
167
|
+
require_origin(handler)
|
|
168
|
+
destination = return_location(strict_query(handler, %w[version page resource fragment]))
|
|
169
|
+
expiry = Time.now.to_f + PENDING_LIFETIME
|
|
170
|
+
state = SecureRandom.urlsafe_base64(32)
|
|
171
|
+
verifier = SecureRandom.urlsafe_base64(48)
|
|
172
|
+
write_cookie(handler, 'pending', { 'state' => state, 'verifier' => verifier, 'return' => destination, 'expires' => expiry })
|
|
173
|
+
query = URI.decode_www_form(@endpoints.fetch(:authorize).query.to_s)
|
|
174
|
+
query += { client_id: @client_id, response_type: 'code', redirect_uri: @redirect_uri.to_s,
|
|
175
|
+
scope: @scope.join(' '), state:, code_challenge_method: 'S256',
|
|
176
|
+
code_challenge: Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) }.to_a
|
|
177
|
+
target = @endpoints.fetch(:authorize).dup
|
|
178
|
+
target.query = URI.encode_www_form(query)
|
|
179
|
+
handler.redirect(target.to_s, 302)
|
|
180
|
+
rescue Failure, Invalid
|
|
181
|
+
failure_page(handler)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def callback(handler)
|
|
185
|
+
protect_response(handler)
|
|
186
|
+
require_origin(handler)
|
|
187
|
+
pending = read_cookie(handler, 'pending')
|
|
188
|
+
clear_cookie(handler, 'pending')
|
|
189
|
+
values = strict_query(handler, %w[code state error error_description error_uri])
|
|
190
|
+
unless pending && !values.has_key?('error') && nonce?(values['state']) &&
|
|
191
|
+
secure_equal?(values['state'], pending['state']) && token?(values['code'])
|
|
192
|
+
raise Failure, 'Documentation sign-in failed'
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
data = JSON.parse(@transport.post(@endpoints.fetch(:token), credentials.merge(
|
|
196
|
+
'grant_type' => 'authorization_code', 'code' => values.fetch('code'),
|
|
197
|
+
'redirect_uri' => @redirect_uri.to_s, 'code_verifier' => pending.fetch('verifier')
|
|
198
|
+
)))
|
|
199
|
+
unless data.is_a?(Hash) && token?(data['access_token']) && data['token_type'].to_s.downcase == 'bearer'
|
|
200
|
+
raise Failure, 'Documentation sign-in failed'
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
duration = Float(data['expires_in'])
|
|
204
|
+
raise Failure, 'Documentation sign-in failed' unless duration.finite? && duration > 0
|
|
205
|
+
|
|
206
|
+
expiry = Time.now.to_f + [duration, @lifetime].min
|
|
207
|
+
write_cookie(handler, 'session', { 'token' => data.fetch('access_token'), 'expires' => expiry,
|
|
208
|
+
'csrf' => SecureRandom.urlsafe_base64(32) })
|
|
209
|
+
handler.redirect(destination_url(return_location(pending.fetch('return'))), 303)
|
|
210
|
+
rescue Failure, Invalid, JSON::ParserError, ArgumentError, TypeError
|
|
211
|
+
failure_page(handler)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def logout(handler)
|
|
215
|
+
protect_response(handler)
|
|
216
|
+
require_origin(handler)
|
|
217
|
+
switch_user = switch_user_request?(handler)
|
|
218
|
+
session = read_cookie(handler, 'session')
|
|
219
|
+
csrf = handler.params['csrf']
|
|
220
|
+
unless handler.request.env['HTTP_ORIGIN'] == @origin && session && nonce?(csrf) && secure_equal?(csrf, session['csrf'])
|
|
221
|
+
handler.halt(403, 'Invalid documentation sign-out request')
|
|
222
|
+
end
|
|
223
|
+
clear_cookie(handler, 'session')
|
|
224
|
+
clear_cookie(handler, 'pending')
|
|
225
|
+
begin
|
|
226
|
+
@transport.post(@endpoints.fetch(:revoke), credentials.merge('token' => session.fetch('token'), 'token_type_hint' => 'access_token'))
|
|
227
|
+
rescue Failure
|
|
228
|
+
handler.halt(502, { 'content-type' => 'text/html; charset=utf-8' },
|
|
229
|
+
'<p>Local sign-out succeeded, but the server session may remain until it expires.</p>' \
|
|
230
|
+
"<p><a href=\"#{CGI.escapeHTML(destination_url('version' => @server.default_version.to_s))}\">Return to documentation</a></p>")
|
|
231
|
+
end
|
|
232
|
+
if switch_user
|
|
233
|
+
handler.redirect("#{@origin}#{@routes.fetch('login')}?#{URI.encode_www_form(version: @server.default_version.to_s)}", 303)
|
|
234
|
+
end
|
|
235
|
+
handler.redirect(destination_url('version' => @server.default_version.to_s), 303)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
def scalar(value, limit)
|
|
241
|
+
unless value.is_a?(String) && value.bytesize.between?(1, limit) && value.valid_encoding? && !value.match?(/[\x00-\x1f\x7f]/)
|
|
242
|
+
raise Invalid, 'Invalid documentation OAuth configuration'
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
value.dup.freeze
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def endpoint(value, query: true)
|
|
249
|
+
uri = URI.parse(scalar(value, 2048))
|
|
250
|
+
unless uri.is_a?(URI::HTTPS) && uri.host && !uri.userinfo && !uri.fragment && (query || !uri.query) &&
|
|
251
|
+
uri.path.start_with?('/') && !uri.path.match?(/[\\\s]/)
|
|
252
|
+
raise Invalid, 'Documentation OAuth requires fixed HTTPS URLs'
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
uri.freeze
|
|
256
|
+
rescue URI::InvalidURIError
|
|
257
|
+
raise Invalid, 'Documentation OAuth requires fixed HTTPS URLs'
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def documentation_origin?(handler)
|
|
261
|
+
handler.request.scheme == 'https' && handler.request.base_url == @origin
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def require_origin(handler)
|
|
265
|
+
handler.halt(400, 'Documentation sign-in requires its configured HTTPS origin') unless documentation_origin?(handler)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def protect_response(handler, referrer_policy: 'no-referrer')
|
|
269
|
+
handler.headers('Cache-Control' => 'private, no-store', 'Referrer-Policy' => referrer_policy)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def strict_query(handler, fields)
|
|
273
|
+
query = handler.request.query_string
|
|
274
|
+
raise Failure, 'Invalid documentation OAuth request' if query.bytesize > 8192 || query.match?(/%(?![0-9a-f]{2})/i)
|
|
275
|
+
|
|
276
|
+
pairs = URI.decode_www_form(query)
|
|
277
|
+
unless pairs.all? { |key, value| fields.include?(key) && value.bytesize <= 2048 && value.valid_encoding? } &&
|
|
278
|
+
pairs.map(&:first).uniq.length == pairs.length
|
|
279
|
+
raise Failure, 'Invalid documentation OAuth request'
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
pairs.to_h
|
|
283
|
+
rescue ArgumentError
|
|
284
|
+
raise Failure, 'Invalid documentation OAuth request'
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def return_location(fields)
|
|
288
|
+
unless fields.is_a?(Hash) && (fields.keys - %w[version page resource fragment]).empty? &&
|
|
289
|
+
fields.values.all? { |value| value.is_a?(String) && value.bytesize <= 256 }
|
|
290
|
+
raise Failure, 'Invalid documentation return location'
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
selected = { 'version' => fields.fetch('version', @server.default_version.to_s) }.merge(fields)
|
|
294
|
+
unless @server.versions.map(&:to_s).include?(selected['version']) &&
|
|
295
|
+
(!selected['page'] || %w[overview authentication resources].include?(selected['page'])) &&
|
|
296
|
+
(!selected['resource'] || selected['resource'].match?(/\A[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*\z/)) &&
|
|
297
|
+
!(selected['resource'] && selected['page']) &&
|
|
298
|
+
(!selected['fragment'] || selected['fragment'].match?(/\A[A-Za-z0-9_.-]{1,256}\z/))
|
|
299
|
+
raise Failure, 'Invalid documentation return location'
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
selected
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def destination_url(destination)
|
|
306
|
+
version = @server.versions.find { |value| value.to_s == destination.fetch('version') }
|
|
307
|
+
path = @server.version_prefix(version)
|
|
308
|
+
query = destination.slice('page', 'resource')
|
|
309
|
+
result = "#{@origin}#{path}"
|
|
310
|
+
result += "?#{URI.encode_www_form(query)}" unless query.empty?
|
|
311
|
+
result += "##{destination['fragment']}" if destination['fragment']
|
|
312
|
+
result
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def credentials
|
|
316
|
+
{ 'client_id' => @client_id, 'client_secret' => @client_secret }
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def switch_user_request?(handler)
|
|
320
|
+
request = handler.request
|
|
321
|
+
parameters = handler.params
|
|
322
|
+
pairs = URI.decode_www_form(request.query_string)
|
|
323
|
+
form = request.get_header('rack.request.form_vars')
|
|
324
|
+
pairs.concat(URI.decode_www_form(form)) if form
|
|
325
|
+
selected = pairs.select { |name, _| name.match?(/\Aswitch_user(?:\[|\z)/) }
|
|
326
|
+
return false if selected.empty? && !parameters.has_key?('switch_user')
|
|
327
|
+
|
|
328
|
+
unless @switch_user && selected == [%w[switch_user 1]] && parameters['switch_user'] == '1' &&
|
|
329
|
+
[nil, 'application/x-www-form-urlencoded'].include?(request.media_type)
|
|
330
|
+
handler.halt(403, 'Invalid documentation switch request')
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
true
|
|
334
|
+
rescue ArgumentError
|
|
335
|
+
handler.halt(403, 'Invalid documentation switch request')
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def nonce?(value)
|
|
339
|
+
value.is_a?(String) && value.match?(/\A[A-Za-z0-9_-]{32,128}\z/)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def token?(value)
|
|
343
|
+
value.is_a?(String) && value.bytesize.between?(1, 4096) && value.match?(/\A[\x21-\x7e]+\z/)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def secure_equal?(left, right)
|
|
347
|
+
left.bytesize == right.bytesize && ActiveSupport::SecurityUtils.secure_compare(left, right)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def write_cookie(handler, kind, payload)
|
|
351
|
+
expiry = Time.at(payload.fetch('expires'))
|
|
352
|
+
encrypted = @encryptor.encrypt_and_sign(payload, purpose: @purposes.fetch(kind), expires_at: expiry)
|
|
353
|
+
attributes = { value: encrypted, path: @path, secure: true, httponly: true, same_site: :lax, expires: expiry }
|
|
354
|
+
if Rack::Utils.set_cookie_header(@names.fetch(kind), attributes).bytesize > COOKIE_LIMIT
|
|
355
|
+
raise Failure, 'Documentation sign-in failed'
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
handler.response.set_cookie(@names.fetch(kind), attributes)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def read_cookie(handler, kind)
|
|
362
|
+
raw = handler.request.cookies[@names.fetch(kind)]
|
|
363
|
+
return unless raw
|
|
364
|
+
raise Failure, 'Invalid documentation session' unless raw.bytesize <= COOKIE_LIMIT
|
|
365
|
+
|
|
366
|
+
payload = @encryptor.decrypt_and_verify(raw, purpose: @purposes.fetch(kind))
|
|
367
|
+
expected = kind == 'pending' ? %w[state verifier return expires] : %w[token expires csrf]
|
|
368
|
+
unless payload.is_a?(Hash) && payload.keys.sort == expected.sort &&
|
|
369
|
+
payload['expires'].is_a?(Numeric) && payload['expires'].finite? && payload['expires'] > Time.now.to_f
|
|
370
|
+
raise Failure, 'Invalid documentation session'
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
valid = if kind == 'pending'
|
|
374
|
+
nonce?(payload['state']) && nonce?(payload['verifier']) && return_location(payload['return'])
|
|
375
|
+
else
|
|
376
|
+
token?(payload['token']) && nonce?(payload['csrf'])
|
|
377
|
+
end
|
|
378
|
+
raise Failure, 'Invalid documentation session' unless valid
|
|
379
|
+
|
|
380
|
+
payload
|
|
381
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage, Failure, ArgumentError, TypeError
|
|
382
|
+
clear_cookie(handler, kind)
|
|
383
|
+
handler.instance_variable_set(:@documentation_oauth_message, 'Your documentation session ended. Sign in again.') if kind == 'session'
|
|
384
|
+
nil
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def clear_cookie(handler, kind)
|
|
388
|
+
handler.response.set_cookie(@names.fetch(kind), value: '', path: @path, secure: true,
|
|
389
|
+
httponly: true, same_site: :lax, expires: Time.at(0), max_age: 0)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def failure_page(handler)
|
|
393
|
+
protect_response(handler)
|
|
394
|
+
link = "#{@origin}#{@routes.fetch('login')}"
|
|
395
|
+
handler.halt(400, { 'content-type' => 'text/html; charset=utf-8' },
|
|
396
|
+
"<p>Documentation sign-in failed. <a href=\"#{CGI.escapeHTML(link)}\">Try again</a>.</p>")
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|