Skip to content

Commit cc1003e

Browse files
Merge pull request #21300 from opf/www-scope
Add scope-hint to WWW-Authenticate header
2 parents 180c704 + 1673045 commit cc1003e

4 files changed

Lines changed: 60 additions & 37 deletions

File tree

lib_static/open_project/authentication.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,9 @@ def response_header(
273273
request_headers)
274274

275275
header = %{#{scheme} realm="#{scope_realm(scope)}"}
276-
header << %{, error="#{error}"} if error
277-
header << %{, error_description="#{error_description}"} if error && error_description
276+
header << %{, scope="#{escape_string scope}"} if scope && scheme == "Bearer"
277+
header << %{, error="#{escape_string error}"} if error
278+
header << %{, error_description="#{escape_string error_description}"} if error && error_description
278279
header
279280
end
280281

@@ -285,6 +286,10 @@ def auth_schemes(scope)
285286
.select { |_, info| scope.nil? or info.strategies.intersect?(strategies) }
286287
.keys
287288
end
289+
290+
def escape_string(string)
291+
string.to_s.dump[1..-2]
292+
end
288293
end
289294

290295
module AuthHeaders

lib_static/open_project/authentication/strategies/warden/fail_with_header.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def fail_with_header!(error:, error_description: nil)
3737
headers(
3838
"WWW-Authenticate" => OpenProject::Authentication::WWWAuthenticate.response_header(
3939
default_auth_scheme: "Bearer",
40+
scope:,
4041
error:,
4142
error_description:
4243
)

spec/requests/api/v3/authentication_spec.rb

Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
#
2424
# You should have received a copy of the GNU General Public License
2525
# along with this program; if not, write to the Free Software
26-
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
2727
#
2828
# See COPYRIGHT and LICENSE files for more details.
2929
#++
@@ -64,39 +64,43 @@
6464

6565
context "with an invalid access token" do
6666
let(:oauth_access_token) { "1337" }
67+
let(:expected_www_auth_header) { 'Bearer realm="OpenProject API", scope="api_v3", error="invalid_token"' }
6768

6869
it "returns unauthorized" do
6970
expect(last_response).to have_http_status :unauthorized
70-
expect(last_response.header["WWW-Authenticate"]).to eq('Bearer realm="OpenProject API", error="invalid_token"')
71+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
7172
expect(JSON.parse(last_response.body)).to eq(error_response_body)
7273
end
7374
end
7475

7576
context "with a revoked access token" do
7677
let(:token) { create(:oauth_access_token, resource_owner: user, revoked_at: DateTime.now) }
7778
let(:oauth_access_token) { token.plaintext_token }
79+
let(:expected_www_auth_header) { 'Bearer realm="OpenProject API", scope="api_v3", error="invalid_token"' }
7880

7981
it "returns unauthorized" do
8082
expect(last_response).to have_http_status :unauthorized
81-
expect(last_response.header["WWW-Authenticate"]).to eq('Bearer realm="OpenProject API", error="invalid_token"')
83+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
8284
expect(JSON.parse(last_response.body)).to eq(error_response_body)
8385
end
8486
end
8587

8688
context "when the token's application is disabled" do
8789
let(:token) { create(:oauth_access_token, resource_owner: user, application: create(:oauth_application, enabled: false)) }
8890
let(:oauth_access_token) { token.plaintext_token }
91+
let(:expected_www_auth_header) { 'Bearer realm="OpenProject API", scope="api_v3", error="invalid_token"' }
8992

9093
it "returns unauthorized" do
9194
expect(last_response).to have_http_status :unauthorized
92-
expect(last_response.header["WWW-Authenticate"]).to eq('Bearer realm="OpenProject API", error="invalid_token"')
95+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
9396
expect(JSON.parse(last_response.body)).to eq(error_response_body)
9497
end
9598
end
9699

97100
context "with an expired access token" do
98101
let(:token) { create(:oauth_access_token, resource_owner: user) }
99102
let(:oauth_access_token) { token.plaintext_token }
103+
let(:expected_www_auth_header) { 'Bearer realm="OpenProject API", scope="api_v3", error="invalid_token"' }
100104

101105
around do |ex|
102106
Timecop.freeze(Time.current + (token.expires_in + 5).seconds) do
@@ -106,18 +110,19 @@
106110

107111
it "returns unauthorized" do
108112
expect(last_response).to have_http_status :unauthorized
109-
expect(last_response.header["WWW-Authenticate"]).to eq('Bearer realm="OpenProject API", error="invalid_token"')
113+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
110114
expect(JSON.parse(last_response.body)).to eq(error_response_body)
111115
end
112116
end
113117

114118
context "with wrong scope" do
115119
let(:token) { create(:oauth_access_token, resource_owner: user, scopes: "unknown_scope") }
116120
let(:oauth_access_token) { token.plaintext_token }
121+
let(:expected_www_auth_header) { 'Bearer realm="OpenProject API", scope="api_v3", error="insufficient_scope"' }
117122

118123
it "returns forbidden" do
119124
expect(last_response).to have_http_status :forbidden
120-
expect(last_response.header["WWW-Authenticate"]).to eq('Bearer realm="OpenProject API", error="insufficient_scope"')
125+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
121126
expect(JSON.parse(last_response.body)).to eq(error_response_body)
122127
end
123128
end
@@ -126,6 +131,7 @@
126131
let(:token) { create(:oauth_access_token, resource_owner: user, application:) }
127132
let(:application) { create(:oauth_application) }
128133
let(:oauth_access_token) { token.plaintext_token }
134+
let(:expected_www_auth_header) { 'Bearer realm="OpenProject API", scope="api_v3", error="invalid_token"' }
129135

130136
around do |ex|
131137
user.destroy
@@ -134,7 +140,7 @@
134140

135141
it "returns unauthorized" do
136142
expect(last_response).to have_http_status :unauthorized
137-
expect(last_response.header["WWW-Authenticate"]).to eq('Bearer realm="OpenProject API", error="invalid_token"')
143+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
138144
expect(JSON.parse(last_response.body)).to eq(error_response_body)
139145
end
140146

@@ -143,7 +149,7 @@
143149

144150
it "returns unauthorized" do
145151
expect(last_response).to have_http_status :unauthorized
146-
expect(last_response.header["WWW-Authenticate"]).to eq('Bearer realm="OpenProject API", error="invalid_token"')
152+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
147153
expect(JSON.parse(last_response.body)).to eq(error_response_body)
148154
end
149155
end
@@ -153,10 +159,11 @@
153159
let(:token) { create(:oauth_access_token, resource_owner: user) }
154160
let(:oauth_access_token) { token.plaintext_token }
155161
let(:user) { create(:user, :locked) }
162+
let(:expected_www_auth_header) { 'Bearer realm="OpenProject API", scope="api_v3", error="invalid_token"' }
156163

157164
it "returns unauthorized" do
158165
expect(last_response).to have_http_status :unauthorized
159-
expect(last_response.header["WWW-Authenticate"]).to eq('Bearer realm="OpenProject API", error="invalid_token"')
166+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
160167
expect(JSON.parse(last_response.body)).to eq(error_response_body)
161168
end
162169
end
@@ -189,10 +196,11 @@
189196
context "and the client credentials user is locked" do
190197
let(:user) { create(:user, :locked) }
191198
let(:expected_message) { "You did not provide the correct credentials." }
199+
let(:expected_www_auth_header) { 'Bearer realm="OpenProject API", scope="api_v3", error="invalid_token"' }
192200

193201
it "returns unauthorized" do
194202
expect(last_response).to have_http_status :unauthorized
195-
expect(last_response.header["WWW-Authenticate"]).to eq('Bearer realm="OpenProject API", error="invalid_token"')
203+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
196204
expect(JSON.parse(last_response.body)).to eq(error_response_body)
197205
end
198206
end
@@ -491,6 +499,11 @@ def set_basic_auth_header(user, password)
491499
let(:token_issuer) { "https://keycloak.local/realms/master" }
492500
let(:token_scope) { "email profile api_v3" }
493501
let(:expected_message) { "You did not provide the correct credentials." }
502+
let(:expected_www_auth_header) do
503+
"Bearer realm=\"OpenProject API\", scope=\"api_v3\", error=\"#{expected_error}\", " \
504+
"error_description=\"#{expected_error_description}\""
505+
end
506+
let(:expected_error) { "invalid_token" }
494507
let(:keys_request_stub) do
495508
stub_request(:get, "https://keycloak.local/realms/master/protocol/openid-connect/certs")
496509
.to_return(status: 200, body: JWT::JWK::Set.new(jwk_response).export.to_json, headers: {})
@@ -514,66 +527,64 @@ def set_basic_auth_header(user, password)
514527

515528
context "when token is issued by provider not configured in OP" do
516529
let(:token_issuer) { "https://eve.example.com" }
530+
let(:expected_error_description) { "The access token issuer is unknown" }
517531

518532
it "fails with HTTP 401 Unauthorized" do
519533
get resource
520534
expect(last_response).to have_http_status :unauthorized
521-
expect(last_response.header["WWW-Authenticate"])
522-
.to eq(%{Bearer realm="OpenProject API", error="invalid_token", error_description="The access token issuer is unknown"})
535+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
523536
expect(JSON.parse(last_response.body)).to eq(error_response_body)
524537
end
525538
end
526539

527540
context "when token signature algorithm is not supported" do
528541
let(:token) { JWT.encode(payload, "secret", "HS256", { kid: "97AmyvoS8BFFRfm585GPgA16G1H2V22EdxxuAYUuoKk" }) }
542+
let(:expected_error_description) { "Token signature algorithm HS256 is not supported" }
529543

530544
it "fails with HTTP 401 Unauthorized" do
531545
get resource
532546
expect(last_response).to have_http_status :unauthorized
533-
error = "Token signature algorithm HS256 is not supported"
534-
expect(last_response.header["WWW-Authenticate"])
535-
.to eq(%{Bearer realm="OpenProject API", error="invalid_token", error_description="#{error}"})
547+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
536548
expect(JSON.parse(last_response.body)).to eq(error_response_body)
537549
end
538550
end
539551

540552
context "when aud does not contain client_id" do
541553
let(:token_aud) { ["Lisa", "Bart"] }
554+
let(:expected_error_description) { 'Invalid audience. Expected https://openproject.local, received [\"Lisa\", \"Bart\"]' }
542555

543556
it "fails with HTTP 401 Unauthorized" do
544557
get resource
545558

546559
expect(last_response).to have_http_status :unauthorized
547-
error = 'Invalid audience. Expected https://openproject.local, received ["Lisa", "Bart"]'
548-
expect(last_response.header["WWW-Authenticate"])
549-
.to eq(%{Bearer realm="OpenProject API", error="invalid_token", error_description="#{error}"})
560+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
550561
expect(JSON.parse(last_response.body)).to eq(error_response_body)
551562
end
552563
end
553564

554565
context "when the scope does not permit access to APIv3" do
555566
let(:token_scope) { "profile email" }
567+
let(:expected_error) { "insufficient_scope" }
568+
let(:expected_error_description) { "Requires scope api_v3 to access this resource." }
556569

557570
it "fails with HTTP 403 Forbidden" do
558571
get resource
559572

560573
expect(last_response).to have_http_status :forbidden
561-
error = "Requires scope api_v3 to access this resource."
562-
expect(last_response.header["WWW-Authenticate"])
563-
.to eq(%{Bearer realm="OpenProject API", error="insufficient_scope", error_description="#{error}"})
574+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
564575
expect(JSON.parse(last_response.body)).to eq(error_response_body)
565576
end
566577
end
567578

568579
context "when access token has expired already" do
569580
let(:token_exp) { 5.minutes.ago }
581+
let(:expected_error_description) { "Signature has expired" }
570582

571583
it "fails with HTTP 401 Unauthorized" do
572584
get resource
573585

574586
expect(last_response).to have_http_status :unauthorized
575-
expect(last_response.header["WWW-Authenticate"])
576-
.to eq(%{Bearer realm="OpenProject API", error="invalid_token", error_description="Signature has expired"})
587+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
577588
expect(JSON.parse(last_response.body)).to eq(error_response_body)
578589
end
579590

@@ -590,40 +601,37 @@ def set_basic_auth_header(user, password)
590601

591602
context "when kid is absent in keycloak keys response" do
592603
let(:jwk_response) { JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), kid: "your-kid", use: "sig", alg: "RS256") }
604+
let(:expected_error_description) { "The signature key ID is unknown" }
593605

594606
it "fails with HTTP 401 Unauthorized" do
595607
get resource
596608
expect(last_response).to have_http_status :unauthorized
609+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
597610
expect(JSON.parse(last_response.body)).to eq(error_response_body)
598-
error = "The signature key ID is unknown"
599-
expect(last_response.header["WWW-Authenticate"])
600-
.to eq(%{Bearer realm="OpenProject API", error="invalid_token", error_description="#{error}"})
601611
end
602612
end
603613

604614
context "when user identified by token is not known" do
605615
let(:user) { create(:user, authentication_provider: create(:oidc_provider)) }
616+
let(:expected_error_description) { "The user identified by the token is not known" }
606617

607618
it "fails with HTTP 401 Unauthorized" do
608619
get resource
609620
expect(last_response).to have_http_status :unauthorized
621+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
610622
expect(JSON.parse(last_response.body)).to eq(error_response_body)
611-
error = "The user identified by the token is not known"
612-
expect(last_response.header["WWW-Authenticate"])
613-
.to eq(%{Bearer realm="OpenProject API", error="invalid_token", error_description="#{error}"})
614623
end
615624
end
616625

617626
context "when user identified by token is locked" do
618627
let(:user) { create(:user, :locked, authentication_provider: create(:oidc_provider), external_id: token_sub) }
628+
let(:expected_error_description) { "The user account is locked" }
619629

620630
it "fails with HTTP 401 Unauthorized" do
621631
get resource
622632
expect(last_response).to have_http_status :unauthorized
633+
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
623634
expect(JSON.parse(last_response.body)).to eq(error_response_body)
624-
error = "The user account is locked"
625-
expect(last_response.header["WWW-Authenticate"])
626-
.to eq(%{Bearer realm="OpenProject API", error="invalid_token", error_description="#{error}"})
627635
end
628636
end
629637
end

spec/requests/scim_v2/authentication_spec.rb

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
#
2424
# You should have received a copy of the GNU General Public License
2525
# along with this program; if not, write to the Free Software
26-
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
2727
#
2828
# See COPYRIGHT and LICENSE files for more details.
2929
#++
@@ -125,24 +125,33 @@
125125

126126
context "when scim_v2 scope is missing in token" do
127127
let(:token_scope) { "api_v3" }
128+
let(:expected_www_auth_header) do
129+
'Bearer realm="OpenProject API", scope="scim_v2", error="insufficient_scope", ' \
130+
'error_description="Requires scope scim_v2 to access this resource."'
131+
end
128132

129133
it do
130134
get "/scim_v2/ServiceProviderConfig", {}, headers
131135
expect(last_response.body).to eq("insufficient_scope")
132-
expect(last_response.headers["WWW-Authenticate"]).to eq("Bearer realm=\"OpenProject API\", error=\"insufficient_scope\", error_description=\"Requires scope scim_v2 to access this resource.\"")
136+
expect(last_response.headers["WWW-Authenticate"]).to eq(expected_www_auth_header)
133137
expect(last_response).to have_http_status(401)
134138
end
135139
end
136140

137141
context "when token_sub does not match a service_account" do
142+
let(:expected_www_auth_header) do
143+
'Bearer realm="OpenProject API", scope="scim_v2", error="invalid_token", ' \
144+
'error_description="The user identified by the token is not known"'
145+
end
146+
138147
before { service_account.user_auth_provider_links.delete_all }
139148

140149
it do
141150
get "/scim_v2/ServiceProviderConfig", {}, headers
142151

143152
expect(last_response).to have_http_status(401)
144153
expect(last_response.body).to eq("invalid_token")
145-
expect(last_response.headers["WWW-Authenticate"]).to eq("Bearer realm=\"OpenProject API\", error=\"invalid_token\", error_description=\"The user identified by the token is not known\"")
154+
expect(last_response.headers["WWW-Authenticate"]).to eq(expected_www_auth_header)
146155
end
147156
end
148157
end

0 commit comments

Comments
 (0)