haveapi 0.31.2 → 0.32.1

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