haveapi 0.31.1 → 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.
Files changed (122) 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 +157 -0
  8. data/doc/documentation-ui.md +192 -0
  9. data/doc/index.md +3 -6
  10. data/doc/performance.md +39 -12
  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 +399 -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 +912 -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/json_activation_spec.rb +78 -1
  115. data/spec/performance/release_lineage_spec.rb +4 -3
  116. data/spec/performance/runner_spec.rb +1 -0
  117. data/test_support/all_client_performance_fixture.rb +6 -10
  118. data/test_support/documentation_oauth_fixture.rb +54 -0
  119. data/test_support/documentation_performance_fixture.rb +19 -1
  120. data/test_support/dynamic_documentation_api.rb +35 -10
  121. data/test_support/dynamic_python_login_spec.rb +3 -11
  122. 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