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.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/benchmarks/documentation.rb +29 -6
- data/benchmarks/endpoints.rb +153 -24
- data/benchmarks/spec/client_examples_spec.rb +109 -37
- data/doc/client-examples.md +218 -45
- data/doc/documentation-oauth.md +188 -0
- data/doc/documentation-ui.md +192 -0
- data/doc/index.md +3 -6
- data/doc/performance.md +48 -14
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/authentication/chain.rb +19 -0
- data/lib/haveapi/authentication/oauth2/provider.rb +7 -1
- data/lib/haveapi/client_example.rb +143 -15
- data/lib/haveapi/client_examples/curl.rb +30 -20
- data/lib/haveapi/client_examples/dynamic_helpers.rb +1 -1
- data/lib/haveapi/client_examples/go_client.rb +9 -2
- data/lib/haveapi/client_examples/http.rb +9 -4
- data/lib/haveapi/client_examples/js_async_client.rb +7 -0
- data/lib/haveapi/client_examples/js_client.rb +47 -36
- data/lib/haveapi/client_examples/php_client.rb +161 -75
- data/lib/haveapi/client_examples/python_async_client.rb +3 -0
- data/lib/haveapi/client_examples/python_client.rb +61 -47
- data/lib/haveapi/client_examples/ruby_cli.rb +27 -18
- data/lib/haveapi/client_examples/ruby_client.rb +80 -31
- data/lib/haveapi/client_examples/rust_client.rb +20 -9
- data/lib/haveapi/client_examples/ts_async_client.rb +7 -0
- data/lib/haveapi/client_examples/ts_client.rb +3 -0
- data/lib/haveapi/client_examples/ts_generated_client.rb +10 -2
- data/lib/haveapi/documentation_context.rb +7 -2
- data/lib/haveapi/documentation_helpers.rb +223 -0
- data/lib/haveapi/documentation_oauth.rb +411 -0
- data/lib/haveapi/documentation_search.rb +225 -0
- data/lib/haveapi/documentation_search_index.rb +79 -0
- data/lib/haveapi/documentation_validators.rb +179 -0
- data/lib/haveapi/public/css/documentation.css +343 -0
- data/lib/haveapi/public/js/documentation.js +992 -0
- data/lib/haveapi/server.rb +144 -86
- data/lib/haveapi/shared_documentation_helpers.rb +54 -0
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/doc_layout.erb +16 -0
- data/lib/haveapi/views/doc_sidebars/create-client.erb +3 -3
- data/lib/haveapi/views/doc_sidebars/json-schema.erb +1 -1
- data/lib/haveapi/views/doc_sidebars/protocol.erb +5 -5
- data/lib/haveapi/views/documentation/search_form.erb +10 -0
- data/lib/haveapi/views/documentation/search_index.erb +1 -0
- data/lib/haveapi/views/documentation/search_results.erb +20 -0
- data/lib/haveapi/views/index.erb +2 -4
- data/lib/haveapi/views/main_layout.erb +12 -6
- data/lib/haveapi/views/shared_documentation.erb +12 -0
- data/lib/haveapi/views/shared_sidebar.erb +25 -0
- data/lib/haveapi/views/version_page/auth_body.erb +12 -33
- data/lib/haveapi/views/version_page/client_overview.erb +40 -0
- data/lib/haveapi/views/version_page/example_panels.erb +18 -0
- data/lib/haveapi/views/version_page/resource_body.erb +91 -132
- data/lib/haveapi/views/version_page/resource_directory.erb +14 -0
- data/lib/haveapi/views/version_page/usage.erb +7 -0
- data/lib/haveapi/views/version_page.erb +36 -117
- data/lib/haveapi/views/version_sidebar/auth_nav.erb +12 -16
- data/lib/haveapi/views/version_sidebar/auth_resource_nav.erb +19 -0
- data/lib/haveapi/views/version_sidebar/authentication_control.erb +26 -0
- data/lib/haveapi/views/version_sidebar/authentication_status.erb +3 -0
- data/lib/haveapi/views/version_sidebar/brand.erb +31 -0
- data/lib/haveapi/views/version_sidebar/resource_nav.erb +11 -20
- data/lib/haveapi/views/version_sidebar.erb +59 -48
- data/spec/authentication/basic_spec.rb +10 -10
- data/spec/authentication/oauth2_chain_spec.rb +72 -0
- data/spec/authentication/oauth2_token_validation_spec.rb +38 -0
- data/spec/client_examples/assets/documentation_test.cjs +817 -0
- data/spec/client_examples/context_performance_spec.rb +5 -4
- data/spec/client_examples/context_spec.rb +23 -2
- data/spec/client_examples/detail_spec.rb +175 -0
- data/spec/client_examples/dynamic_acceptance.md +59 -26
- data/spec/client_examples/dynamic_acceptance.rb +164 -44
- data/spec/client_examples/dynamic_acceptance_spec.rb +140 -23
- data/spec/client_examples/dynamic_fixture.rb +4 -3
- data/spec/client_examples/dynamic_js_spec.rb +20 -11
- data/spec/client_examples/dynamic_metadata_spec.rb +69 -0
- data/spec/client_examples/dynamic_mounted_paths_spec.rb +9 -7
- data/spec/client_examples/dynamic_python_spec.rb +62 -11
- data/spec/client_examples/go_spec.rb +14 -1
- data/spec/client_examples/presentation_spec.rb +247 -0
- data/spec/client_examples/profile_mount_spec.rb +40 -13
- data/spec/client_examples/rust_client_spec.rb +35 -1
- data/spec/client_examples/shell_spec.rb +268 -0
- data/spec/client_examples/syntax_spec.rb +192 -0
- data/spec/client_examples/ts_generated_client_spec.rb +15 -2
- data/spec/client_examples/typescript_profiles_spec.rb +1 -1
- data/spec/documentation/auth_filtering_spec.rb +9 -4
- data/spec/documentation/canonical_routes_spec.rb +89 -0
- data/spec/documentation/client_examples_spec.rb +39 -5
- data/spec/documentation/client_overview_spec.rb +145 -0
- data/spec/documentation/current_user_html_escaping_spec.rb +7 -2
- data/spec/documentation/example_variants_spec.rb +93 -0
- data/spec/documentation/fixtures/search_index.json +476 -0
- data/spec/documentation/identity_and_guides_spec.rb +130 -0
- data/spec/documentation/identity_context_spec.rb +180 -0
- data/spec/documentation/navigation_spec.rb +296 -0
- data/spec/documentation/oauth_fixture_spec.rb +39 -0
- data/spec/documentation/oauth_spec.rb +1058 -0
- data/spec/documentation/oauth_transport_spec.rb +45 -0
- data/spec/documentation/performance_spec.rb +47 -6
- data/spec/documentation/reference_headings_spec.rb +190 -0
- data/spec/documentation/search_index_routes_spec.rb +202 -0
- data/spec/documentation/search_index_spec.rb +85 -0
- data/spec/documentation/search_routes_spec.rb +199 -0
- data/spec/documentation/search_spec.rb +134 -0
- data/spec/documentation/shared_pages_spec.rb +240 -0
- data/spec/documentation/validators_spec.rb +123 -0
- data/spec/documentation_spec.rb +3 -3
- data/spec/packaging_spec.rb +21 -2
- data/spec/performance/documentation_identity_fixture_spec.rb +47 -0
- data/spec/performance/documentation_shapes_spec.rb +97 -0
- data/spec/performance/event_baseline_spec.rb +97 -2
- data/spec/performance/json_activation_spec.rb +78 -1
- data/spec/performance/release_lineage_spec.rb +4 -3
- data/spec/performance/runner_spec.rb +1 -0
- data/test_support/all_client_performance_fixture.rb +6 -10
- data/test_support/documentation_oauth_fixture.rb +54 -0
- data/test_support/documentation_performance_fixture.rb +19 -1
- data/test_support/dynamic_documentation_api.rb +35 -10
- data/test_support/dynamic_python_login_spec.rb +3 -11
- metadata +52 -3
|
@@ -0,0 +1,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
|