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,1058 @@
1
+ require 'spec_helper'
2
+ require 'rack/test'
3
+
4
+ module DocumentationOAuthSpec
5
+ User = Struct.new(:login)
6
+
7
+ class Config < HaveAPI::Authentication::OAuth2::Config
8
+ class << self
9
+ attr_accessor :users, :lookups, :requests
10
+ end
11
+
12
+ def base_url
13
+ 'https://issuer.example'
14
+ end
15
+
16
+ def find_user_by_access_token(request, token)
17
+ self.class.lookups << [@version, token]
18
+ self.class.requests << { authorization: request.env['HTTP_AUTHORIZATION'],
19
+ custom: request.env['HTTP_X_HAVEAPI_OAUTH2_TOKEN'],
20
+ query: request.query_string, parameters: request.params.dup }
21
+ self.class.users[[@version, token]]
22
+ end
23
+ end
24
+
25
+ Provider = HaveAPI::Authentication::OAuth2.with_config(Config)
26
+
27
+ class BasicProvider < HaveAPI::Authentication::Basic::Provider
28
+ protected
29
+
30
+ def find_user(_request, username, password)
31
+ User.new('basic-reader') if username == 'basic-reader' && password == 'basic-password'
32
+ end
33
+ end
34
+
35
+ class SharedSSOProvider
36
+ def initialize
37
+ @authorizations = %w[PRIVATE_ACCESS_TOKEN OTHER_ACCESS_TOKEN]
38
+ @sso_active = true
39
+ end
40
+
41
+ def revoke(parameters)
42
+ @authorizations.delete(parameters.fetch('token'))
43
+ @sso_active = false if @authorizations.empty? || parameters['end_shared_session'] == '1'
44
+ ''
45
+ end
46
+
47
+ def valid_token?(token)
48
+ @authorizations.include?(token)
49
+ end
50
+
51
+ def fresh_authorization_identity
52
+ 'admin' if @sso_active
53
+ end
54
+ end
55
+ end
56
+
57
+ describe HaveAPI::DocumentationOAuth do
58
+ api do
59
+ define_resource(:Public) do
60
+ version 1
61
+ auth false
62
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
63
+ output(:hash) { string :value }
64
+ authorize { allow }
65
+ end
66
+ end
67
+ define_resource(:Secret) do
68
+ version 1
69
+ desc 'ROLE_FILTERED_RESOURCE'
70
+ auth true
71
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
72
+ output(:hash) { string :value }
73
+ authorize { |user| user&.login == 'admin' ? allow : deny }
74
+ def exec
75
+ { value: 'private' }
76
+ end
77
+ end
78
+ define_action(:Create, superclass: HaveAPI::Actions::Default::Create) do
79
+ input(:hash) { string :value }
80
+ output(:hash) { string :value }
81
+ authorize { |user| user&.login == 'admin' ? allow : deny }
82
+ end
83
+ end
84
+ end
85
+
86
+ let(:oauth_options) do
87
+ { client_id: 'docs-client', client_secret: 'CLIENT_SECRET_NOT_FOR_HTML', session_secret: 's' * 64,
88
+ redirect_uri: 'https://docs.example/api/_documentation/oauth/callback', scope: ['all'], session_lifetime: 1800 }
89
+ end
90
+ let(:transport) { instance_double(described_class::Transport) }
91
+ let(:token_reply) { JSON.generate(access_token: 'PRIVATE_ACCESS_TOKEN', token_type: 'Bearer', expires_in: 3600, refresh_token: 'IGNORED_REFRESH') }
92
+ let(:post_authenticated) { [] }
93
+ let(:api_server) { build_api_server }
94
+
95
+ def build_api_server(versions: [1, 2], basic: false)
96
+ server = HaveAPI::Server.new(self.class::ApiModule)
97
+ server.use_version(versions)
98
+ server.default_version = 1
99
+ server.auth_chain << DocumentationOAuthSpec::BasicProvider if basic
100
+ server.auth_chain << DocumentationOAuthSpec::Provider
101
+ server.documentation_oauth(**oauth_options)
102
+ server.connect_hook(:post_authenticated) do |ret, user|
103
+ post_authenticated << user&.login
104
+ ret
105
+ end
106
+ server.mount('/api/')
107
+ server
108
+ end
109
+
110
+ before do
111
+ DocumentationOAuthSpec::Config.users = { [1, 'PRIVATE_ACCESS_TOKEN'] => DocumentationOAuthSpec::User.new('admin') }
112
+ DocumentationOAuthSpec::Config.lookups = []
113
+ DocumentationOAuthSpec::Config.requests = []
114
+ allow(described_class::Transport).to receive(:new).and_return(transport)
115
+ allow(transport).to receive(:post).and_return(token_reply)
116
+ end
117
+
118
+ def app
119
+ api_server.app
120
+ end
121
+
122
+ def secret_action_path(name)
123
+ actions = api_server.routes.fetch(1).fetch(:resources).fetch(self.class::ApiModule::Secret).fetch(:actions)
124
+ actions.find { |action, _| action.name.split('::').last.downcase == name.to_s }.last
125
+ end
126
+
127
+ def begin_login(query = {})
128
+ get 'https://docs.example/api/_documentation/oauth/login', { version: '1', resource: 'secret', fragment: 'root-secret-index' }.merge(query)
129
+ expect(last_response.status).to eq(302)
130
+ URI.decode_www_form(URI(last_response.location).query).to_h
131
+ end
132
+
133
+ def sign_in
134
+ authorization = begin_login
135
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'one-use-code', state: authorization.fetch('state')
136
+ expect(last_response.status).to eq(303)
137
+ authorization
138
+ end
139
+
140
+ def cookie_headers
141
+ Array(last_response.headers['set-cookie']).join("\n")
142
+ end
143
+
144
+ def capture_controls
145
+ controls = []
146
+ allow(api_server.documentation_oauth).to receive(:controls).and_wrap_original do |method, *args|
147
+ result = method.call(*args)
148
+ controls << result
149
+ result
150
+ end
151
+ controls
152
+ end
153
+
154
+ def session_csrf
155
+ get 'https://docs.example/api/v1/'
156
+ last_response.body[/name="csrf" value="([^"]+)"/, 1].tap { |value| expect(value).not_to be_nil }
157
+ end
158
+
159
+ def expect_switch_refused(cookie, query, body, origin: 'https://docs.example', headers: {})
160
+ name, value = cookie
161
+ post "https://docs.example/api/_documentation/oauth/logout#{query}", body,
162
+ { 'HTTP_ORIGIN' => origin, 'CONTENT_TYPE' => 'application/x-www-form-urlencoded' }.merge(headers)
163
+ expect(last_response.status).to eq(403)
164
+ expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer')
165
+ expect(cookie_headers).to be_empty
166
+ expect(rack_mock_session.cookie_jar.to_hash[name]).to eq(value)
167
+ expect(transport).to have_received(:post).once
168
+ end
169
+
170
+ def expect_existing_session_after_failed_callback(cookie)
171
+ name, value = cookie
172
+ expect(last_response.status).to eq(400)
173
+ expect(last_response.body).to include('Documentation sign-in failed', 'Try again')
174
+ expect(cookie_headers).to include(name.sub(/_session\z/, '_pending'), 'max-age=0')
175
+ expect(cookie_headers).not_to include("#{name}=")
176
+ expect(rack_mock_session.cookie_jar.to_hash[name]).to eq(value)
177
+ expect(rack_mock_session.cookie_jar.to_hash[name.sub(/_session\z/, '_pending')].to_s).to be_empty
178
+
179
+ get 'https://docs.example/api/v1/?resource=secret'
180
+ expect(last_response.status).to eq(200)
181
+ expect(last_response.body).to include('<span class="docs-identity">admin</span>')
182
+ expect(DocumentationOAuthSpec::Config.lookups.last).to eq([1, 'PRIVATE_ACCESS_TOKEN'])
183
+ expect(post_authenticated.last).to eq('admin')
184
+ end
185
+
186
+ it 'uses fixed issuer URLs, PKCE and encrypted scoped cookies, then restores a clean destination' do
187
+ authorization = sign_in
188
+ expect(authorization).to include('client_id' => 'docs-client', 'code_challenge_method' => 'S256', 'scope' => 'all')
189
+ expect(transport).to have_received(:post) do |uri, parameters|
190
+ expect(uri.to_s).to eq('https://issuer.example/api/_auth/oauth2/token')
191
+ verifier = parameters.fetch('code_verifier')
192
+ expect(authorization['code_challenge']).to eq(Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false))
193
+ expect(parameters).to include('client_id' => 'docs-client', 'client_secret' => oauth_options[:client_secret])
194
+ end
195
+ expect(last_response.location).to eq('https://docs.example/api/v1/?resource=secret#root-secret-index')
196
+ expect(cookie_headers).to include('secure', 'httponly', 'samesite=lax', 'path=/api/')
197
+ expect(cookie_headers).not_to include('PRIVATE_ACCESS_TOKEN', oauth_options[:client_secret], 'IGNORED_REFRESH', 'domain=')
198
+ get 'https://docs.example/api/v1/?resource=secret'
199
+ expect(last_response.status).to eq(200)
200
+ expect(last_response.body).to include('ROLE_FILTERED_RESOURCE')
201
+ expect(last_response.body).not_to include('PRIVATE_ACCESS_TOKEN', oauth_options[:client_secret], 'IGNORED_REFRESH')
202
+ expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'strict-origin')
203
+ expect(post_authenticated).to include('admin')
204
+ end
205
+
206
+ context 'with a single-version index containing account controls' do
207
+ let(:api_server) { build_api_server(versions: [1]) }
208
+
209
+ [false, true].each do |signed_in|
210
+ it "uses strict-origin on documentation GET and HEAD responses when signed #{signed_in ? 'in' : 'out'}" do
211
+ sign_in if signed_in
212
+ %w[/api/ /api/v1/ /api/v1/?resource=secret /api/v1/?page=authentication].each do |path|
213
+ %i[get head].each do |method|
214
+ public_send(method, "https://docs.example#{path}")
215
+ expect(last_response.status).to eq(200)
216
+ expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'strict-origin')
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
222
+
223
+ it 'keeps no-referrer on login, callback and sign-out redirects and failures' do
224
+ authorization = begin_login
225
+ expect(last_response.headers).to include('Referrer-Policy' => 'no-referrer')
226
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'one-use-code', state: authorization.fetch('state')
227
+ expect(last_response.status).to eq(303)
228
+ expect(last_response.headers).to include('Referrer-Policy' => 'no-referrer')
229
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'unsolicited', state: 'x' * 43
230
+ expect(last_response.status).to eq(400)
231
+ expect(last_response.headers).to include('Referrer-Policy' => 'no-referrer')
232
+ get 'https://docs.example/api/_documentation/oauth/login', page: 'arbitrary'
233
+ expect(last_response.status).to eq(400)
234
+ expect(last_response.headers).to include('Referrer-Policy' => 'no-referrer')
235
+ csrf = session_csrf
236
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf: 'wrong' }, 'HTTP_ORIGIN' => 'https://docs.example'
237
+ expect(last_response.status).to eq(403)
238
+ expect(last_response.headers).to include('Referrer-Policy' => 'no-referrer')
239
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf: }, 'HTTP_ORIGIN' => 'https://docs.example'
240
+ expect(last_response.status).to eq(303)
241
+ expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer')
242
+ end
243
+
244
+ %w[application/x-www-form-urlencoded multipart/form-data].each do |encoding|
245
+ it "accepts same-origin sign-out with session CSRF using #{encoding}" do
246
+ sign_in
247
+ csrf = session_csrf
248
+ if encoding == 'multipart/form-data'
249
+ boundary = 'documentation-logout-boundary'
250
+ body = "--#{boundary}\r\nContent-Disposition: form-data; name=\"csrf\"\r\n\r\n#{csrf}\r\n--#{boundary}--\r\n"
251
+ content_type = "#{encoding}; boundary=#{boundary}"
252
+ else
253
+ body = URI.encode_www_form(csrf:)
254
+ content_type = encoding
255
+ end
256
+ post 'https://docs.example/api/_documentation/oauth/logout', body,
257
+ 'HTTP_ORIGIN' => 'https://docs.example', 'CONTENT_TYPE' => content_type
258
+ expect(last_response.status).to eq(303)
259
+ expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer')
260
+ expect(last_response.location).to eq('https://docs.example/api/v1/')
261
+ expect(cookie_headers).to include('_session=', '_pending=', 'max-age=0')
262
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'), hash_including('token' => 'PRIVATE_ACCESS_TOKEN'))
263
+ end
264
+ end
265
+
266
+ context 'with valid session CSRF and a switch-capable client' do
267
+ let(:oauth_options) { super().merge(switch_user: true, switch_user_revoke_params: { 'end_shared_session' => '1' }) }
268
+
269
+ [nil, 'null', 'https://foreign.example', 'http://docs.example', 'https://docs.example:444'].each do |origin|
270
+ [false, true].each do |switch_user|
271
+ it "rejects #{switch_user ? 'switching' : 'sign-out'} from #{origin || 'an absent Origin'} without sign-out effects" do
272
+ sign_in
273
+ csrf = session_csrf
274
+ cookie = rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
275
+ fields = { csrf: }
276
+ fields[:switch_user] = '1' if switch_user
277
+ expect_switch_refused(cookie, '', URI.encode_www_form(fields), origin:)
278
+ get 'https://docs.example/api/v1/?resource=secret'
279
+ expect(last_response.body).to include('ROLE_FILTERED_RESOURCE', '<span class="docs-identity">admin</span>')
280
+ end
281
+ end
282
+ end
283
+
284
+ it 'accepts a native URL-encoded switch with the exact origin and session CSRF' do
285
+ sign_in
286
+ csrf = session_csrf
287
+ post 'https://docs.example/api/_documentation/oauth/logout', URI.encode_www_form(csrf:, switch_user: '1'),
288
+ 'HTTP_ORIGIN' => 'https://docs.example', 'CONTENT_TYPE' => 'application/x-www-form-urlencoded'
289
+ expect(last_response.status).to eq(303)
290
+ expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer')
291
+ expect(last_response.location).to eq('https://docs.example/api/_documentation/oauth/login?version=1')
292
+ expect(cookie_headers).to include('_session=', '_pending=', 'max-age=0')
293
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'),
294
+ 'client_id' => 'docs-client', 'client_secret' => oauth_options[:client_secret],
295
+ 'token' => 'PRIVATE_ACCESS_TOKEN', 'token_type_hint' => 'access_token',
296
+ 'end_shared_session' => '1')
297
+ end
298
+ end
299
+
300
+ it 'consumes pending state and rejects replay without another token request' do
301
+ authorization = sign_in
302
+ cookie = rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
303
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'one-use-code', state: authorization.fetch('state')
304
+ expect_existing_session_after_failed_callback(cookie)
305
+ expect(transport).to have_received(:post).once
306
+ end
307
+
308
+ context 'with an existing authenticated documentation session' do
309
+ def sign_in_cookie
310
+ sign_in
311
+ rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
312
+ end
313
+
314
+ it 'preserves the session after an unsolicited callback without pending state' do
315
+ cookie = sign_in_cookie
316
+ get 'https://docs.example/api/_documentation/oauth/callback'
317
+ expect_existing_session_after_failed_callback(cookie)
318
+ expect(transport).to have_received(:post).once
319
+ end
320
+
321
+ { 'missing state' => { code: 'code' },
322
+ 'mismatched state' => { code: 'code', state: 'x' * 43 },
323
+ 'malformed state' => { code: 'code', 'state[]' => 'bad' } }.each do |failure, query|
324
+ it "preserves the session after #{failure} and consumes pending state" do
325
+ cookie = sign_in_cookie
326
+ begin_login
327
+ get 'https://docs.example/api/_documentation/oauth/callback', query
328
+ expect_existing_session_after_failed_callback(cookie)
329
+ expect(transport).to have_received(:post).once
330
+ end
331
+ end
332
+
333
+ it 'preserves the session after cancellation with the expected state' do
334
+ cookie = sign_in_cookie
335
+ authorization = begin_login
336
+ get 'https://docs.example/api/_documentation/oauth/callback', error: 'access_denied', state: authorization.fetch('state')
337
+ expect_existing_session_after_failed_callback(cookie)
338
+ expect(transport).to have_received(:post).once
339
+ end
340
+
341
+ it 'preserves the session after a callback with the expected state but no code' do
342
+ cookie = sign_in_cookie
343
+ authorization = begin_login
344
+ get 'https://docs.example/api/_documentation/oauth/callback', state: authorization.fetch('state')
345
+ expect_existing_session_after_failed_callback(cookie)
346
+ expect(transport).to have_received(:post).once
347
+ end
348
+
349
+ it 'preserves the session after duplicate callback fields' do
350
+ cookie = sign_in_cookie
351
+ authorization = begin_login
352
+ state = authorization.fetch('state')
353
+ get "https://docs.example/api/_documentation/oauth/callback?code=code&state=#{state}&state=#{state}"
354
+ expect_existing_session_after_failed_callback(cookie)
355
+ expect(transport).to have_received(:post).once
356
+ end
357
+
358
+ it 'preserves the session when pending state expires' do
359
+ cookie = sign_in_cookie
360
+ authorization = begin_login
361
+ allow(Time).to receive(:now).and_return(Time.now + 601)
362
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'code', state: authorization.fetch('state')
363
+ expect_existing_session_after_failed_callback(cookie)
364
+ expect(transport).to have_received(:post).once
365
+ end
366
+
367
+ it 'preserves the session when token exchange fails' do
368
+ cookie = sign_in_cookie
369
+ authorization = begin_login
370
+ allow(transport).to receive(:post).and_raise(described_class::Failure, 'PRIVATE_PROVIDER_ERROR')
371
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'code', state: authorization.fetch('state')
372
+ expect(last_response.body).not_to include('PRIVATE_PROVIDER_ERROR')
373
+ expect_existing_session_after_failed_callback(cookie)
374
+ expect(transport).to have_received(:post).twice
375
+ end
376
+
377
+ it 'preserves the session when token exchange returns an invalid reply' do
378
+ cookie = sign_in_cookie
379
+ authorization = begin_login
380
+ allow(transport).to receive(:post).and_return('{invalid JSON')
381
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'code', state: authorization.fetch('state')
382
+ expect_existing_session_after_failed_callback(cookie)
383
+ expect(transport).to have_received(:post).twice
384
+ end
385
+ end
386
+
387
+ [nil, 'tampered'].each do |value|
388
+ it "does not create a session after an unsolicited callback with #{value ? 'an invalid' : 'no'} session cookie" do
389
+ if value
390
+ sign_in
391
+ name = rack_mock_session.cookie_jar.to_hash.keys.find { |key| key.end_with?('_session') }
392
+ set_cookie "#{name}=#{value}; path=/api/", URI('https://docs.example/api/')
393
+ end
394
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'unsolicited', state: 'x' * 43
395
+ expect(last_response.status).to eq(400)
396
+ get 'https://docs.example/api/v1/?resource=secret'
397
+ expect(last_response.status).to eq(200)
398
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"')
399
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
400
+ expect(transport).to have_received(:post).exactly(value ? 1 : 0).times
401
+ end
402
+ end
403
+
404
+ it 'returns to the validated resource directory and its fragment after sign-in' do
405
+ get 'https://docs.example/api/_documentation/oauth/login', version: '1', page: 'resources', fragment: 'resources'
406
+ expect(last_response.status).to eq(302)
407
+ authorization = URI.decode_www_form(URI(last_response.location).query).to_h
408
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'code', state: authorization.fetch('state')
409
+ expect(last_response.status).to eq(303)
410
+ expect(last_response.location).to eq('https://docs.example/api/v1/?page=resources#resources')
411
+ end
412
+
413
+ it 'rejects malformed directory selectors and arbitrary return locations' do
414
+ ['?page[]=resources', '?page=resourcez', '?page=resources&resource=secret',
415
+ '?page=resources&return=https://foreign.example/', '?page=resources&fragment=bad%2Fanchor'].each do |query|
416
+ get "https://docs.example/api/_documentation/oauth/login#{query}"
417
+ expect(last_response.status).to eq(400)
418
+ expect(last_response.body).not_to include('foreign.example')
419
+ end
420
+ expect(transport).not_to have_received(:post)
421
+ end
422
+
423
+ it 'rejects a mismatched state, cancellation and duplicate callback inputs without exchanging a code' do
424
+ [{ state: 'x' * 43, code: 'code' }, { error: 'access_denied' }, { 'state[]' => 'bad', code: 'code' }].each do |query|
425
+ begin_login
426
+ get 'https://docs.example/api/_documentation/oauth/callback', query
427
+ expect(last_response.status).to eq(400)
428
+ expect(last_response.body).to include('Try again')
429
+ end
430
+ expect(transport).not_to have_received(:post)
431
+ end
432
+
433
+ it 'rejects an expired pending cookie' do
434
+ authorization = begin_login
435
+ allow(Time).to receive(:now).and_return(Time.now + 601)
436
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'code', state: authorization.fetch('state')
437
+ expect(last_response.status).to eq(400)
438
+ expect(transport).not_to have_received(:post)
439
+ end
440
+
441
+ it 'bounds session expiry by its configured lifetime and clears expired sessions' do
442
+ sign_in
443
+ DocumentationOAuthSpec::Config.users[[1, 'api-token']] = DocumentationOAuthSpec::User.new('admin')
444
+ name, value = rack_mock_session.cookie_jar.to_hash.find { |key, _| key.end_with?('_session') }
445
+ allow(Time).to receive(:now).and_return(Time.now + 1801)
446
+ get 'https://docs.example/api/v1/', {}, 'HTTP_COOKIE' => "#{name}=#{Rack::Utils.escape(value)}", 'HTTP_AUTHORIZATION' => 'Bearer api-token'
447
+ expect(last_response.status).to eq(200)
448
+ expect(last_response.body).not_to include('ROLE_FILTERED_RESOURCE')
449
+ expect(cookie_headers).to include('max-age=0')
450
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
451
+ end
452
+
453
+ it 'clears a tampered cookie and does not make its contents an API credential' do
454
+ sign_in
455
+ name = rack_mock_session.cookie_jar.to_hash.keys.find { |key| key.end_with?('_session') }
456
+ set_cookie "#{name}=tampered; path=/api/", URI('https://docs.example/api/')
457
+ get 'https://docs.example/api/v1/'
458
+ expect(last_response.status).to eq(200)
459
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
460
+ expect(cookie_headers).to include('max-age=0')
461
+ end
462
+
463
+ it 'revalidates the user role and token through the requested version' do
464
+ sign_in
465
+ get 'https://docs.example/api/v2/?resource=secret'
466
+ expect(last_response.status).to eq(404)
467
+ expect(DocumentationOAuthSpec::Config.lookups).to include([2, 'PRIVATE_ACCESS_TOKEN'])
468
+ DocumentationOAuthSpec::Config.users[[1, 'PRIVATE_ACCESS_TOKEN']] = DocumentationOAuthSpec::User.new('member')
469
+ sign_in
470
+ get 'https://docs.example/api/v1/?resource=secret'
471
+ expect(last_response.status).to eq(404)
472
+ end
473
+
474
+ it 'ignores the docs cookie for OPTIONS and API GET and POST actions' do
475
+ sign_in
476
+ options 'https://docs.example/api/v1/'
477
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
478
+ get "https://docs.example#{secret_action_path(:index)}"
479
+ expect(last_response.status).to eq(401)
480
+ post "https://docs.example#{secret_action_path(:create)}", {}, 'CONTENT_TYPE' => 'application/json'
481
+ expect(last_response.status).to eq(401)
482
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
483
+ end
484
+
485
+ it 'uses only the cookie token despite explicit Authorization, custom and query credentials' do
486
+ sign_in
487
+ DocumentationOAuthSpec::Config.users[[1, 'explicit']] = DocumentationOAuthSpec::User.new('member')
488
+ [{ 'HTTP_AUTHORIZATION' => 'Bearer explicit' }, { 'HTTP_AUTHORIZATION' => 'broken' },
489
+ { 'HTTP_X_HAVEAPI_OAUTH2_TOKEN' => 'explicit' }].each do |env|
490
+ DocumentationOAuthSpec::Config.lookups.clear
491
+ post_authenticated.clear
492
+ get 'https://docs.example/api/v1/', {}, env
493
+ expect(last_response.status).to eq(200)
494
+ expect(DocumentationOAuthSpec::Config.lookups).to eq([[1, 'PRIVATE_ACCESS_TOKEN']])
495
+ expect(post_authenticated).to eq(['admin'])
496
+ expect(last_response.body).to include('class="docs-identity">admin</span>')
497
+ expect(DocumentationOAuthSpec::Config.requests.last).to include(
498
+ authorization: env['HTTP_AUTHORIZATION'], custom: env['HTTP_X_HAVEAPI_OAUTH2_TOKEN']
499
+ )
500
+ end
501
+ DocumentationOAuthSpec::Config.lookups.clear
502
+ get 'https://docs.example/api/v1/?access_token=explicit'
503
+ expect(DocumentationOAuthSpec::Config.lookups).to eq([[1, 'PRIVATE_ACCESS_TOKEN']])
504
+ expect(DocumentationOAuthSpec::Config.requests.last).to include(query: 'access_token=explicit', parameters: hash_including('access_token' => 'explicit'))
505
+ end
506
+
507
+ it 'ignores the cookie on another origin and on plain HTTP' do
508
+ sign_in
509
+ name, value = rack_mock_session.cookie_jar.to_hash.find { |key, _| key.end_with?('_session') }
510
+ cookie = "#{name}=#{Rack::Utils.escape(value)}"
511
+ get 'http://docs.example/api/v1/', {}, 'HTTP_COOKIE' => cookie
512
+ get 'https://foreign.example/api/v1/', {}, 'HTTP_COOKIE' => cookie
513
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
514
+ end
515
+
516
+ it 'ignores explicit token conflicts and metadata-declared API credentials for documentation identity' do
517
+ sign_in
518
+ get 'https://docs.example/api/v1/', {}, 'HTTP_AUTHORIZATION' => 'Bearer one', 'HTTP_X_HAVEAPI_OAUTH2_TOKEN' => 'two'
519
+ expect(last_response.status).to eq(200)
520
+ expect(last_response.body).to include('class="docs-identity">admin</span>')
521
+ expect(DocumentationOAuthSpec::Config.requests.last).to include(authorization: 'Bearer one', custom: 'two')
522
+ metadata = api_server.auth_chain.provider_descriptions(1)
523
+ allow(api_server.auth_chain).to receive(:provider_descriptions).and_return(metadata.merge(
524
+ token: { http_header: 'X-Alternate-Token', query_parameter: :_alternate }
525
+ ))
526
+ get 'https://docs.example/api/v1/', {}, 'HTTP_X_ALTERNATE_TOKEN' => ''
527
+ get 'https://docs.example/api/v1/?_alternate='
528
+ expect(DocumentationOAuthSpec::Config.lookups).to eq(Array.new(3) { [1, 'PRIVATE_ACCESS_TOKEN'] })
529
+ end
530
+
531
+ it 'keeps documentation anonymous without a valid cookie despite API credentials' do
532
+ DocumentationOAuthSpec::Config.users[[1, 'api-token']] = DocumentationOAuthSpec::User.new('admin')
533
+ headers = { 'HTTP_AUTHORIZATION' => 'Bearer api-token', 'HTTP_X_HAVEAPI_OAUTH2_TOKEN' => 'another-token' }
534
+ [nil, 'tampered'].each do |cookie|
535
+ env = headers.dup
536
+ env['HTTP_COOKIE'] = "haveapi_docs_#{Digest::SHA256.hexdigest('/api/')[0, 12]}_session=#{cookie}" if cookie
537
+ get 'https://docs.example/api/v1/?access_token=query-token', {}, env
538
+ expect(last_response.status).to eq(200)
539
+ expect(last_response.headers['www-authenticate']).to be_nil
540
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"')
541
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
542
+ end
543
+ end
544
+
545
+ it 'validates documentation HEAD through the cookie token without API credential fallback' do
546
+ sign_in
547
+ post_authenticated.clear
548
+ head 'https://docs.example/api/v1/', {}, 'HTTP_AUTHORIZATION' => 'Bearer other'
549
+ expect(last_response.status).to eq(200)
550
+ expect(last_response.body).to eq('')
551
+ expect(DocumentationOAuthSpec::Config.lookups).to eq([[1, 'PRIVATE_ACCESS_TOKEN']])
552
+ expect(post_authenticated).to eq(['admin'])
553
+ expect(DocumentationOAuthSpec::Config.requests.last[:authorization]).to eq('Bearer other')
554
+ end
555
+
556
+ it 'resolves multiversion index identity independently for each version without the API chain' do
557
+ sign_in
558
+ DocumentationOAuthSpec::Config.users[[2, 'PRIVATE_ACCESS_TOKEN']] = DocumentationOAuthSpec::User.new('member')
559
+ allow(api_server.auth_chain).to receive(:authenticate).and_call_original
560
+ allow(api_server).to receive(:describe).and_call_original
561
+ post_authenticated.clear
562
+ get 'https://docs.example/api/', {}, 'HTTP_AUTHORIZATION' => 'Bearer api-token'
563
+ expect(last_response.status).to eq(200)
564
+ expect(DocumentationOAuthSpec::Config.lookups).to eq([[1, 'PRIVATE_ACCESS_TOKEN'], [2, 'PRIVATE_ACCESS_TOKEN']])
565
+ expect(post_authenticated).to eq(%w[admin member])
566
+ expect(api_server).to have_received(:describe).once
567
+ expect(api_server.auth_chain).not_to have_received(:authenticate)
568
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"')
569
+ end
570
+
571
+ it 'still uses explicit API credentials for OPTIONS without authenticating through the docs cookie' do
572
+ sign_in
573
+ DocumentationOAuthSpec::Config.users[[1, 'api-token']] = DocumentationOAuthSpec::User.new('member')
574
+ options 'https://docs.example/api/v1/', {}, 'HTTP_AUTHORIZATION' => 'Bearer api-token'
575
+ expect(last_response.status).to eq(200)
576
+ expect(DocumentationOAuthSpec::Config.lookups).to eq([[1, 'api-token']])
577
+ expect(last_response.body).not_to include('ROLE_FILTERED_RESOURCE')
578
+ end
579
+
580
+ it 'revalidates the cookie on keyed and conditional search requests and rejects a changed permission key' do
581
+ sign_in
582
+ get 'https://docs.example/api/v1/'
583
+ key = last_response.body[/data-search-view-key="([a-f0-9]{64})"/, 1]
584
+ expect(key).not_to be_nil
585
+ url = "https://docs.example/api/v1/?page=resources&search_index=#{key}"
586
+ DocumentationOAuthSpec::Config.lookups.clear
587
+ get url, {}, 'HTTP_AUTHORIZATION' => 'Bearer one', 'HTTP_X_HAVEAPI_OAUTH2_TOKEN' => 'two'
588
+ expect(last_response.status).to eq(200)
589
+ expect(last_response.headers['cache-control']).to eq('private, max-age=86400, immutable')
590
+ get url, {}, 'HTTP_IF_NONE_MATCH' => %("#{key}")
591
+ expect(last_response.status).to eq(304)
592
+ expect(DocumentationOAuthSpec::Config.lookups).to eq(Array.new(2) { [1, 'PRIVATE_ACCESS_TOKEN'] })
593
+ DocumentationOAuthSpec::Config.users[[1, 'PRIVATE_ACCESS_TOKEN']] = DocumentationOAuthSpec::User.new('member')
594
+ get url
595
+ expect(last_response.status).to eq(409)
596
+ expect(last_response.body).not_to include('data-documentation-search-index')
597
+ expect(DocumentationOAuthSpec::Config.lookups).to eq(Array.new(3) { [1, 'PRIVATE_ACCESS_TOKEN'] })
598
+ end
599
+
600
+ it 'does not privately cache a matching anonymous index while clearing an invalid cookie' do
601
+ get 'https://docs.example/api/v1/'
602
+ key = last_response.body[/data-search-view-key="([a-f0-9]{64})"/, 1]
603
+ expect(key).not_to be_nil
604
+ name = "haveapi_docs_#{Digest::SHA256.hexdigest('/api/')[0, 12]}_session"
605
+ get "https://docs.example/api/v1/?page=resources&search_index=#{key}", {}, 'HTTP_COOKIE' => "#{name}=tampered"
606
+ expect(last_response.status).to eq(200)
607
+ expect(last_response.headers['cache-control']).to eq('private, no-store')
608
+ expect(last_response.headers['etag']).to be_nil
609
+ expect(cookie_headers).to include("#{name}=", 'max-age=0')
610
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
611
+ end
612
+
613
+ it 'clears a revoked cookie and remains anonymous instead of trying a valid API token' do
614
+ sign_in
615
+ DocumentationOAuthSpec::Config.users.delete([1, 'PRIVATE_ACCESS_TOKEN'])
616
+ DocumentationOAuthSpec::Config.users[[1, 'api-token']] = DocumentationOAuthSpec::User.new('admin')
617
+ get 'https://docs.example/api/v1/', {}, 'HTTP_AUTHORIZATION' => 'Bearer api-token'
618
+ expect(last_response.status).to eq(200)
619
+ expect(last_response.headers['www-authenticate']).to be_nil
620
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"')
621
+ expect(DocumentationOAuthSpec::Config.lookups).to eq([[1, 'PRIVATE_ACCESS_TOKEN']])
622
+ expect(cookie_headers).to include('max-age=0')
623
+ end
624
+
625
+ it 'binds encrypted sessions to the configured client even with the same server secret and mount' do
626
+ sign_in
627
+ name, value = rack_mock_session.cookie_jar.to_hash.find { |key, _| key.end_with?('_session') }
628
+ other = HaveAPI::Server.new(self.class::ApiModule)
629
+ other.use_version(1)
630
+ other.auth_chain << DocumentationOAuthSpec::Provider
631
+ other.documentation_oauth(**oauth_options, client_id: 'different-client')
632
+ other.mount('/api/')
633
+ response = Rack::MockRequest.new(other.app).get('https://docs.example/api/v1/',
634
+ 'HTTP_HOST' => 'docs.example',
635
+ 'HTTP_COOKIE' => "#{name}=#{Rack::Utils.escape(value)}")
636
+ expect(response.status).to eq(200)
637
+ expect(response.body).not_to include('ROLE_FILTERED_RESOURCE')
638
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
639
+ end
640
+
641
+ it 'requires same-origin and session CSRF before revocation' do
642
+ sign_in
643
+ post 'https://docs.example/api/_documentation/oauth/logout', csrf: 'wrong'
644
+ expect(last_response.status).to eq(403)
645
+ get 'https://docs.example/api/v1/'
646
+ csrf = last_response.body[/name="csrf" value="([^"]+)"/, 1]
647
+ expect(csrf).not_to be_nil
648
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf: }, 'HTTP_ORIGIN' => 'https://foreign.example'
649
+ expect(last_response.status).to eq(403)
650
+ expect(transport).to have_received(:post).once
651
+ get 'https://docs.example/api/v1/?resource=secret'
652
+ expect(last_response.status).to eq(200)
653
+ expect(last_response.body).to include('ROLE_FILTERED_RESOURCE')
654
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf: }, 'HTTP_ORIGIN' => 'https://docs.example'
655
+ expect(last_response.status).to eq(303)
656
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'), hash_including('token' => 'PRIVATE_ACCESS_TOKEN'))
657
+ expect(cookie_headers).to include('max-age=0')
658
+ DocumentationOAuthSpec::Config.lookups.clear
659
+ get 'https://docs.example/api/v1/?resource=secret'
660
+ expect(last_response.status).to eq(200)
661
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"')
662
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
663
+ end
664
+
665
+ it 'clears local cookies and reports the revoke failure without leaking details' do
666
+ sign_in
667
+ get 'https://docs.example/api/v1/'
668
+ csrf = last_response.body[/name="csrf" value="([^"]+)"/, 1]
669
+ allow(transport).to receive(:post).and_raise(described_class::Failure, 'PRIVATE_PROVIDER_ERROR')
670
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf: }, 'HTTP_ORIGIN' => 'https://docs.example'
671
+ expect(last_response.status).to eq(502)
672
+ expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer')
673
+ expect(last_response.body).to include('Local sign-out succeeded', 'may remain until it expires')
674
+ expect(last_response.body).not_to include('PRIVATE_PROVIDER_ERROR', 'PRIVATE_ACCESS_TOKEN')
675
+ expect(cookie_headers).to include('max-age=0')
676
+ end
677
+
678
+ it 'disables user switching by default even for an authenticated documentation cookie' do
679
+ sign_in
680
+ controls = capture_controls
681
+ csrf = session_csrf
682
+ expect(controls.last).to include(signed_in: true)
683
+ expect(controls.last).not_to have_key(:switch_user)
684
+ cookie = rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
685
+ expect_switch_refused(cookie, '', URI.encode_www_form(csrf:, switch_user: '1'))
686
+ end
687
+
688
+ it 'requires a strict Boolean switch-user capability' do
689
+ [nil, 0, 1, 'true', 'false', [], {}].each do |value|
690
+ expect { described_class.new(**oauth_options, switch_user: value) }.to raise_error(described_class::Invalid)
691
+ end
692
+ expect { described_class.new(**oauth_options, switch_user: false) }.not_to raise_error
693
+ end
694
+
695
+ it 'requires static string keys and values for switch-user revoke parameters' do
696
+ [nil, [], 'PRIVATE_VALUE', { end_shared_session: 'PRIVATE_VALUE' }, { 'extra' => nil },
697
+ { 'extra' => true }, { 'extra' => 1 }, { 'extra' => [] }, { 'extra' => { 'nested' => 'PRIVATE_VALUE' } }].each do |value|
698
+ expect { described_class.new(**oauth_options, switch_user_revoke_params: value) }
699
+ .to raise_error(described_class::Invalid, 'Invalid documentation OAuth switch-user revoke parameters')
700
+ end
701
+ end
702
+
703
+ %w[client_id client_secret token token_type_hint].each do |field|
704
+ it "rejects overriding required revoke field #{field} in configuration" do
705
+ expect { described_class.new(**oauth_options, switch_user_revoke_params: { field => 'PRIVATE_VALUE' }) }
706
+ .to raise_error(described_class::Invalid, 'Invalid documentation OAuth switch-user revoke parameters')
707
+ end
708
+ end
709
+
710
+ it 'keeps configured switch-user revoke parameters inert when switching is disabled' do
711
+ oauth_options[:switch_user_revoke_params] = { 'end_shared_session' => '1' }
712
+ sign_in
713
+ controls = capture_controls
714
+ csrf = session_csrf
715
+ expect(controls.last).not_to have_key(:switch_user)
716
+ cookie = rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
717
+ expect_switch_refused(cookie, '', URI.encode_www_form(csrf:, switch_user: '1'))
718
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf: }, 'HTTP_ORIGIN' => 'https://docs.example'
719
+ expect(last_response.status).to eq(303)
720
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'),
721
+ 'client_id' => 'docs-client', 'client_secret' => oauth_options[:client_secret],
722
+ 'token' => 'PRIVATE_ACCESS_TOKEN', 'token_type_hint' => 'access_token')
723
+ end
724
+
725
+ [false, true].each do |configured|
726
+ context "with shared SSO and #{configured ? 'configured' : 'default'} switch-user revoke parameters" do
727
+ let(:oauth_options) do
728
+ super().merge(switch_user: true).tap do |options|
729
+ options[:switch_user_revoke_params] = { 'end_shared_session' => '1' } if configured
730
+ end
731
+ end
732
+
733
+ it 'revokes only the documentation token and lets the provider decide whether fresh authorization reuses SSO' do
734
+ provider = DocumentationOAuthSpec::SharedSSOProvider.new
735
+ allow(transport).to receive(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'), anything) do |_, parameters|
736
+ provider.revoke(parameters)
737
+ end
738
+ sign_in
739
+ csrf = session_csrf
740
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf:, switch_user: '1' }, 'HTTP_ORIGIN' => 'https://docs.example'
741
+ expect(last_response.status).to eq(303)
742
+ fields = { 'client_id' => 'docs-client', 'client_secret' => oauth_options[:client_secret],
743
+ 'token' => 'PRIVATE_ACCESS_TOKEN', 'token_type_hint' => 'access_token' }
744
+ fields['end_shared_session'] = '1' if configured
745
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'), fields)
746
+ expect(provider.valid_token?('PRIVATE_ACCESS_TOKEN')).to be(false)
747
+ expect(provider.valid_token?('OTHER_ACCESS_TOKEN')).to be(true)
748
+ get last_response.location
749
+ expect(last_response.status).to eq(302)
750
+ expect(provider.fresh_authorization_identity).to eq(configured ? nil : 'admin')
751
+ end
752
+ end
753
+ end
754
+
755
+ context 'with user switching enabled' do
756
+ let(:oauth_options) { super().merge(switch_user: true, switch_user_revoke_params: { 'end_shared_session' => '1' }) }
757
+
758
+ it 'requires an authenticated documentation session before switching' do
759
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf: 'x' * 43, switch_user: '1' },
760
+ 'HTTP_ORIGIN' => 'https://docs.example'
761
+ expect(last_response.status).to eq(403)
762
+ expect(cookie_headers).to be_empty
763
+ expect(transport).not_to have_received(:post)
764
+ end
765
+
766
+ it 'requires the configured HTTPS origin before switching' do
767
+ sign_in
768
+ csrf = session_csrf
769
+ name, value = rack_mock_session.cookie_jar.to_hash.find { |key, _| key.end_with?('_session') }
770
+ %w[http://docs.example https://foreign.example].each do |origin|
771
+ post "#{origin}/api/_documentation/oauth/logout", { csrf:, switch_user: '1' },
772
+ 'HTTP_ORIGIN' => 'https://docs.example', 'HTTP_COOKIE' => "#{name}=#{Rack::Utils.escape(value)}"
773
+ expect(last_response.status).to eq(400)
774
+ expect(cookie_headers).to be_empty
775
+ end
776
+ expect(transport).to have_received(:post).once
777
+ end
778
+
779
+ it 'copies configured revoke fields before the caller mutates its hash, keys or values' do
780
+ key = +'end_shared_session'
781
+ value = +'1'
782
+ parameters = {}.compare_by_identity
783
+ parameters[key] = value
784
+ oauth_options[:switch_user_revoke_params] = parameters
785
+ app
786
+ key.replace('changed')
787
+ value.replace('changed')
788
+ parameters.clear
789
+ parameters['token'] = 'BROWSER_TOKEN'
790
+ sign_in
791
+ csrf = session_csrf
792
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf:, switch_user: '1' }, 'HTTP_ORIGIN' => 'https://docs.example'
793
+ expect(last_response.status).to eq(303)
794
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'),
795
+ 'client_id' => 'docs-client', 'client_secret' => oauth_options[:client_secret],
796
+ 'token' => 'PRIVATE_ACCESS_TOKEN', 'token_type_hint' => 'access_token',
797
+ 'end_shared_session' => '1')
798
+ end
799
+
800
+ it 'sends the original revoke form on ordinary sign-out even if the browser supplies extra fields' do
801
+ sign_in
802
+ csrf = session_csrf
803
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf:, end_shared_session: '1', token: 'BROWSER_TOKEN' },
804
+ 'HTTP_ORIGIN' => 'https://docs.example'
805
+ expect(last_response.status).to eq(303)
806
+ expect(last_response.location).to eq('https://docs.example/api/v1/')
807
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'),
808
+ 'client_id' => 'docs-client', 'client_secret' => oauth_options[:client_secret],
809
+ 'token' => 'PRIVATE_ACCESS_TOKEN', 'token_type_hint' => 'access_token')
810
+ end
811
+
812
+ it 'offers switching only after validated documentation-cookie authentication' do
813
+ controls = capture_controls
814
+ get 'https://docs.example/api/v1/'
815
+ expect(controls.last).to include(signed_in: false, csrf_token: nil)
816
+ expect(controls.last).not_to have_key(:switch_user)
817
+ sign_in
818
+ get 'https://docs.example/api/v1/'
819
+ expect(controls.last).to include(signed_in: true, switch_user: true)
820
+ name = rack_mock_session.cookie_jar.to_hash.keys.find { |key| key.end_with?('_session') }
821
+ set_cookie "#{name}=tampered; path=/api/", URI('https://docs.example/api/')
822
+ get 'https://docs.example/api/v1/'
823
+ expect(controls.last).to include(signed_in: false, csrf_token: nil)
824
+ expect(controls.last).not_to have_key(:switch_user)
825
+ end
826
+
827
+ it 'revokes and clears the old session before starting fresh state and PKCE at the fixed login' do
828
+ original = sign_in
829
+ expect(original).not_to have_key('end_shared_session')
830
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/token'),
831
+ hash_excluding('end_shared_session'))
832
+ csrf = session_csrf
833
+ post 'https://docs.example/api/_documentation/oauth/logout',
834
+ { csrf:, switch_user: '1', version: '2', return: 'https://foreign.example/', scope: 'other',
835
+ client_id: 'BROWSER_CLIENT', client_secret: 'BROWSER_SECRET', token: 'BROWSER_TOKEN',
836
+ token_type_hint: 'refresh_token', end_shared_session: '0', unexpected: 'BROWSER_EXTRA' },
837
+ 'HTTP_ORIGIN' => 'https://docs.example'
838
+ expect(last_response.status).to eq(303)
839
+ expect(last_response.location).to eq('https://docs.example/api/_documentation/oauth/login?version=1')
840
+ expect(cookie_headers).to include('_session=', '_pending=', 'max-age=0')
841
+ expect(transport).to have_received(:post).with(have_attributes(path: '/api/_auth/oauth2/revoke'),
842
+ 'client_id' => 'docs-client', 'client_secret' => oauth_options[:client_secret],
843
+ 'token' => 'PRIVATE_ACCESS_TOKEN', 'token_type_hint' => 'access_token',
844
+ 'end_shared_session' => '1')
845
+ get last_response.location
846
+ expect(last_response.status).to eq(302)
847
+ fresh = URI.decode_www_form(URI(last_response.location).query).to_h
848
+ expect(fresh).to include('scope' => 'all', 'code_challenge_method' => 'S256')
849
+ expect(fresh['state']).not_to eq(original['state'])
850
+ expect(fresh['code_challenge']).not_to eq(original['code_challenge'])
851
+ expect(fresh.keys & %w[prompt select_account end_shared_session]).to be_empty
852
+ get 'https://docs.example/api/_documentation/oauth/callback', error: 'access_denied', state: fresh.fetch('state')
853
+ expect(last_response.status).to eq(400)
854
+ DocumentationOAuthSpec::Config.lookups.clear
855
+ get 'https://docs.example/api/v1/'
856
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"')
857
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
858
+ expect(transport).to have_received(:post).twice
859
+ end
860
+
861
+ [['', 'switch_user=0'], ['', 'switch_user='], ['', 'switch_user=true'],
862
+ ['', 'switch_user=1&switch_user=1'], ['', 'switch_user[]=1'],
863
+ ['', 'switch_user[mode]=1'], ['?switch_user=1', 'switch_user=1'],
864
+ ['?switch_user=1&switch_user=1', '']].each do |query, fields|
865
+ it "rejects malformed or repeated switch fields #{query} #{fields} before any sign-out effect" do
866
+ sign_in
867
+ csrf = session_csrf
868
+ cookie = rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
869
+ expect_switch_refused(cookie, query, "#{URI.encode_www_form(csrf:)}&#{fields}")
870
+ end
871
+ end
872
+
873
+ it 'rejects multipart switching without extending the normal logout parser' do
874
+ sign_in
875
+ csrf = session_csrf
876
+ cookie = rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
877
+ boundary = 'documentation-switch-boundary'
878
+ body = { csrf:, switch_user: '1' }.map do |name, value|
879
+ "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{name}\"\r\n\r\n#{value}\r\n"
880
+ end.join + "--#{boundary}--\r\n"
881
+ expect_switch_refused(cookie, '', body, headers: { 'CONTENT_TYPE' => "multipart/form-data; boundary=#{boundary}" })
882
+ end
883
+
884
+ it 'requires the session CSRF and same origin before switching' do
885
+ sign_in
886
+ csrf = session_csrf
887
+ cookie = rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
888
+ expect_switch_refused(cookie, '', URI.encode_www_form(switch_user: '1'))
889
+ expect_switch_refused(cookie, '', URI.encode_www_form(csrf: 'x' * 43, switch_user: '1'))
890
+ expect_switch_refused(cookie, '', URI.encode_www_form(csrf:, switch_user: '1'), origin: 'https://foreign.example')
891
+ end
892
+
893
+ it 'keeps truthful local sign-out failure and does not redirect to fresh login when revocation fails' do
894
+ sign_in
895
+ csrf = session_csrf
896
+ allow(transport).to receive(:post).and_raise(described_class::Failure, 'PRIVATE_REVOKE_ERROR')
897
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf:, switch_user: '1' }, 'HTTP_ORIGIN' => 'https://docs.example'
898
+ expect(last_response.status).to eq(502)
899
+ expect(last_response.headers).to include('Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer')
900
+ expect(last_response.headers['location']).to be_nil
901
+ expect(last_response.body).to include('Local sign-out succeeded', 'may remain until it expires')
902
+ expect(last_response.body).not_to include('PRIVATE_REVOKE_ERROR')
903
+ expect(cookie_headers).to include('_session=', '_pending=', 'max-age=0')
904
+ DocumentationOAuthSpec::Config.lookups.clear
905
+ get 'https://docs.example/api/v1/'
906
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"')
907
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
908
+ expect(transport).to have_received(:post).twice
909
+ end
910
+
911
+ context 'with one version and an ordinary Basic provider alongside OAuth' do
912
+ let(:api_server) { build_api_server(versions: [1], basic: true) }
913
+
914
+ it 'presents the validated cookie identity and switch capability on the single-version index' do
915
+ sign_in
916
+ controls = capture_controls
917
+ get 'https://docs.example/api/'
918
+ expect(last_response.status).to eq(200)
919
+ expect(last_response.body).to include('data-documentation-menu', 'admin', 'name="switch_user" value="1"')
920
+ expect(controls.last).to include(signed_in: true, switch_user: true)
921
+ expect(DocumentationOAuthSpec::Config.lookups.last).to eq([1, 'PRIVATE_ACCESS_TOKEN'])
922
+ end
923
+
924
+ it 'uses cookie B identity, filtering and controls while Basic A remains an ordinary API credential' do
925
+ sign_in
926
+ csrf = session_csrf
927
+ cookie = rack_mock_session.cookie_jar.to_hash.find { |name, _| name.end_with?('_session') }
928
+ controls = capture_controls
929
+ authorization = "Basic #{Base64.strict_encode64('basic-reader:basic-password')}"
930
+ DocumentationOAuthSpec::Config.lookups.clear
931
+ get 'https://docs.example/api/', {}, 'HTTP_AUTHORIZATION' => authorization
932
+ expect(last_response.status).to eq(200)
933
+ expect(last_response.body).to include('data-documentation-menu', 'class="docs-identity">admin</span>', 'name="switch_user"')
934
+ expect(last_response.body).not_to include('class="docs-identity">basic-reader</span>')
935
+ expect(controls.last).to include(signed_in: true, switch_user: true)
936
+ get 'https://docs.example/api/v1/?resource=secret', {}, 'HTTP_AUTHORIZATION' => authorization
937
+ expect(last_response.status).to eq(200)
938
+ expect(DocumentationOAuthSpec::Config.lookups).to eq([[1, 'PRIVATE_ACCESS_TOKEN'], [1, 'PRIVATE_ACCESS_TOKEN']])
939
+ expect(post_authenticated.last).to eq('admin')
940
+ DocumentationOAuthSpec::Config.lookups.clear
941
+ get "https://docs.example#{secret_action_path(:index)}", {}, 'HTTP_AUTHORIZATION' => authorization
942
+ expect(last_response.status).to eq(403)
943
+ expect(post_authenticated.last).to eq('basic-reader')
944
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
945
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf:, switch_user: '1' },
946
+ 'HTTP_ORIGIN' => 'https://docs.example', 'HTTP_AUTHORIZATION' => authorization
947
+ expect(last_response.status).to eq(303)
948
+ expect(last_response.location).to eq('https://docs.example/api/_documentation/oauth/login?version=1')
949
+ expect(rack_mock_session.cookie_jar.to_hash[cookie.first].to_s).to be_empty
950
+ end
951
+
952
+ it 'keeps cookie B identity for malformed or conflicting API credentials' do
953
+ sign_in
954
+ controls = capture_controls
955
+ [{ 'HTTP_AUTHORIZATION' => 'broken' },
956
+ { 'HTTP_AUTHORIZATION' => 'Bearer one', 'HTTP_X_HAVEAPI_OAUTH2_TOKEN' => 'two' }].each do |headers|
957
+ DocumentationOAuthSpec::Config.lookups.clear
958
+ get 'https://docs.example/api/v1/', {}, headers
959
+ expect(last_response.status).to eq(200)
960
+ expect(last_response.body).to include('class="docs-identity">admin</span>', 'name="switch_user"', 'name="csrf"')
961
+ expect(DocumentationOAuthSpec::Config.lookups).to eq([[1, 'PRIVATE_ACCESS_TOKEN']])
962
+ expect(controls.last).to include(signed_in: true, switch_user: true)
963
+ end
964
+ end
965
+
966
+ it 'keeps absent or tampered documentation sessions anonymous despite valid Basic A' do
967
+ authorization = "Basic #{Base64.strict_encode64('basic-reader:basic-password')}"
968
+ allow(api_server.auth_chain).to receive(:authenticate).and_call_original
969
+ get 'https://docs.example/api/', {}, 'HTTP_AUTHORIZATION' => authorization
970
+ expect(last_response.status).to eq(200)
971
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"', 'name="switch_user"')
972
+ sign_in
973
+ name = rack_mock_session.cookie_jar.to_hash.keys.find { |key| key.end_with?('_session') }
974
+ set_cookie "#{name}=tampered; path=/api/", URI('https://docs.example/api/')
975
+ get 'https://docs.example/api/', {}, 'HTTP_AUTHORIZATION' => authorization
976
+ expect(last_response.status).to eq(200)
977
+ expect(last_response.headers['www-authenticate']).to be_nil
978
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"', 'name="switch_user"')
979
+ expect(api_server.auth_chain).not_to have_received(:authenticate)
980
+ expect(DocumentationOAuthSpec::Config.lookups).to be_empty
981
+ end
982
+
983
+ it 'signs out the documentation cookie with cached Basic present and does not revoke Basic identity' do
984
+ sign_in
985
+ csrf = session_csrf
986
+ authorization = "Basic #{Base64.strict_encode64('basic-reader:basic-password')}"
987
+ post 'https://docs.example/api/_documentation/oauth/logout', { csrf: },
988
+ 'HTTP_ORIGIN' => 'https://docs.example', 'HTTP_AUTHORIZATION' => authorization
989
+ expect(last_response.status).to eq(303)
990
+ expect(last_response.location).to eq('https://docs.example/api/v1/')
991
+ get 'https://docs.example/api/', {}, 'HTTP_AUTHORIZATION' => authorization
992
+ expect(last_response.body).not_to include('class="docs-identity"', 'name="csrf"')
993
+ get "https://docs.example#{secret_action_path(:index)}", {}, 'HTTP_AUTHORIZATION' => authorization
994
+ expect(last_response.status).to eq(403)
995
+ expect(post_authenticated.last).to eq('basic-reader')
996
+ end
997
+ end
998
+ end
999
+
1000
+ [{}, { access_token: 'token', token_type: 'Bearer', expires_in: 0 },
1001
+ { access_token: 'token', token_type: 'Bearer', expires_in: 'Infinity' },
1002
+ { access_token: 'x' * 4096, token_type: 'Bearer', expires_in: 60 }].each do |response|
1003
+ it "rejects invalid token reply shape #{response.keys.join('/')} without issuing an authenticated cookie" do
1004
+ allow(transport).to receive(:post).and_return(JSON.generate(response))
1005
+ authorization = begin_login
1006
+ get 'https://docs.example/api/_documentation/oauth/callback', code: 'code', state: authorization.fetch('state')
1007
+ expect(last_response.status).to eq(400)
1008
+ expect(cookie_headers).to include('max-age=0')
1009
+ expect(last_response.body).not_to include('CLIENT_SECRET_NOT_FOR_HTML')
1010
+ end
1011
+ end
1012
+
1013
+ it 'rejects arbitrary return URLs, arrays and credential-like query keys' do
1014
+ ['?return=https://foreign.example/', '?version[]=1', '?version=1&version=2', '?access_token=secret', '?fragment=bad%2Fanchor'].each do |query|
1015
+ get "https://docs.example/api/_documentation/oauth/login#{query}"
1016
+ expect(last_response.status).to eq(400)
1017
+ expect(last_response.body).not_to include('foreign.example', 'secret')
1018
+ end
1019
+ end
1020
+
1021
+ it 'rejects repeated configuration and configuration after mount without inspecting secrets' do
1022
+ app
1023
+ expect { api_server.documentation_oauth(**oauth_options) }.to raise_error(ArgumentError)
1024
+ expect(api_server.documentation_oauth.inspect).not_to include(oauth_options[:client_secret], oauth_options[:session_secret])
1025
+ unconfigured = HaveAPI::Server.new(self.class::ApiModule)
1026
+ unconfigured.use_version(1)
1027
+ unconfigured.mount('/api/')
1028
+ expect { unconfigured.documentation_oauth(**oauth_options) }.to raise_error(ArgumentError, /before mount/)
1029
+ end
1030
+
1031
+ it 'rejects insecure config, missing secrets, unknown keys and invalid lifetimes/scopes' do
1032
+ [{ redirect_uri: 'http://docs.example/api/_documentation/oauth/callback' }, { session_secret: 'short' },
1033
+ { client_secret: [] }, { unknown: 'PRIVATE' }, { session_lifetime: 0 }, { session_lifetime: 86_401 },
1034
+ { scope: ['all wrong'] }].each do |changes|
1035
+ expect { described_class.new(**oauth_options, **changes) }.to raise_error(described_class::Invalid)
1036
+ end
1037
+ end
1038
+
1039
+ it 'requires a matching mounted callback and OAuth provider metadata' do
1040
+ api = HaveAPI::Server.new(self.class::ApiModule)
1041
+ api.use_version([1, 2])
1042
+ api.default_version = 1
1043
+ api.documentation_oauth(**oauth_options)
1044
+ expect { api.mount('/different/') }.to raise_error(described_class::Invalid)
1045
+ second = HaveAPI::Server.new(self.class::ApiModule)
1046
+ second.use_version(1)
1047
+ second.documentation_oauth(**oauth_options)
1048
+ expect { second.mount('/api/') }.to raise_error(described_class::Invalid)
1049
+ end
1050
+
1051
+ it 'reads only requested-version provider metadata without describing resources' do
1052
+ app
1053
+ allow(api_server).to receive(:describe_version).and_call_original
1054
+ expect(api_server.auth_chain.provider_descriptions(1)).to include(:oauth2)
1055
+ expect(api_server.auth_chain.provider_descriptions('absent')).to eq({})
1056
+ expect(api_server).not_to have_received(:describe_version)
1057
+ end
1058
+ end