diff --git a/modules/openid_connect/app/services/openid_connect/user_tokens/exchange_service.rb b/modules/openid_connect/app/services/openid_connect/user_tokens/exchange_service.rb new file mode 100644 index 000000000000..b32c8b10aa62 --- /dev/null +++ b/modules/openid_connect/app/services/openid_connect/user_tokens/exchange_service.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenIDConnect + module UserTokens + class ExchangeService + include Dry::Monads[:result] + include Dry::Monads::Do.for(:call) + + class Disabled + class << self + include Dry::Monads[:result] + + def call(_) = Failure("Token exchange disabled") + + def supported? = false + end + end + + def initialize(user:) + @user = user + end + + def call(audience) + return Failure("Provider does not support token exchange") unless supported? + + idp_token = yield FetchService.new(user: @user, token_exchange: Disabled) + .access_token_for(audience: UserToken::IDP_AUDIENCE) + + json = yield exchange_token_request(idp_token, audience) + + access_token = json["access_token"] + refresh_token = json["refresh_token"] + return Failure("Token exchange response invalid") if access_token.blank? + + token = store_exchanged_token(audience:, access_token:, refresh_token:) + Success(token) + end + + def supported? + provider&.token_exchange_capable? + end + + private + + def exchange_token_request(access_token, audience) + response = OpenProject.httpx + .basic_auth(provider.client_id, provider.client_secret) + .post(provider.token_endpoint, form: { + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token: access_token, + audience: + }) + response.raise_for_status + + Success(response.json) + rescue HTTPX::Error => e + Failure(e) + end + + def store_exchanged_token(audience:, access_token:, refresh_token:) + token = @user.oidc_user_tokens.where("audiences ? :audience", audience:).first + if token + if token.audiences.size > 1 + raise "Did not expect to update token with multiple audiences (#{token.audiences}) in-place." + end + + token.update!(access_token:, refresh_token:) + else + token = @user.oidc_user_tokens.create!(access_token:, refresh_token:, audiences: [audience]) + end + + token + end + + def provider + @user.authentication_provider + end + end + end +end diff --git a/modules/openid_connect/app/services/openid_connect/user_tokens/fetch_service.rb b/modules/openid_connect/app/services/openid_connect/user_tokens/fetch_service.rb index 45908f1a3670..2baa1a62ea04 100644 --- a/modules/openid_connect/app/services/openid_connect/user_tokens/fetch_service.rb +++ b/modules/openid_connect/app/services/openid_connect/user_tokens/fetch_service.rb @@ -36,10 +36,15 @@ module UserTokens # client_id at an identity provider that OpenProject and the application have in common. class FetchService include Dry::Monads[:result] + include Dry::Monads::Do.for(:access_token_for, :refreshed_access_token_for) - def initialize(user:, jwt_parser: JwtParser.new(verify_audience: false, verify_expiration: false)) + def initialize(user:, + jwt_parser: JwtParser.new(verify_audience: false, verify_expiration: false), + token_exchange: ExchangeService.new(user:), + token_refresh: RefreshService.new(user:, token_exchange:)) @user = user - @provider = user.authentication_provider + @token_exchange = token_exchange + @token_refresh = token_refresh @jwt_parser = jwt_parser end @@ -51,17 +56,14 @@ def initialize(user:, jwt_parser: JwtParser.new(verify_audience: false, verify_e # identified as being expired. There is no guarantee that all access tokens will be properly # recognized as expired, so client's still need to make sure to handle rejected access tokens # properly. Also see #refreshed_access_token_for. + # + # A token exchange is attempted, if the provider supports OAuth 2.0 Token Exchange and a token + # for the target audience either can't be found or it has expired, but has no available refresh token. def access_token_for(audience:) - token = token_with_audience(audience) - token = token.bind do |t| - if expired?(t.access_token) - refresh(t) - else - Success(t) - end - end + token = yield token_with_audience(audience) + token = yield @token_refresh.call(token) if expired?(token.access_token) - token.fmap(&:access_token) + Success(token.access_token) end ## @@ -71,45 +73,24 @@ def access_token_for(audience:) # The access token will always be refreshed before being returned by this method. # It is advised to use this method, after learning that a remote service rejected # an access token, because it was expired. + # + # A token exchange is attempted, if the provider supports OAuth 2.0 Token Exchange and a token + # for the target audience either can't be found or it has expired, but has no available refresh token. def refreshed_access_token_for(audience:) - token_with_audience(audience) - .bind { |t| refresh(t) } - .fmap(&:access_token) + token = yield token_with_audience(audience) + token = yield @token_refresh.call(token) + Success(token.access_token) end private def token_with_audience(aud) token = @user.oidc_user_tokens.where("audiences ? :aud", aud:).first - token ? Success(token) : Failure("No token for given audience") - end - - def refresh(token) - return Failure("Can't refresh the access token") if token.refresh_token.blank? - - refresh_token_request(token.refresh_token).bind do |json| - access_token = json["access_token"] - refresh_token = json["refresh_token"] - break Failure("Refresh token response invalid") if access_token.blank? - - token.update!(access_token:, refresh_token:) - - Success(token) - end - end + return Success(token) if token - def refresh_token_request(refresh_token) - response = OpenProject.httpx - .basic_auth(@provider.client_id, @provider.client_secret) - .post(@provider.token_endpoint, form: { - grant_type: :refresh_token, - refresh_token: - }) - response.raise_for_status + return @token_exchange.call(aud) if @token_exchange.supported? - Success(response.json) - rescue HTTPX::Error => e - Failure(e) + Failure("No token for audience '#{aud}'") end def expired?(token_string) diff --git a/modules/openid_connect/app/services/openid_connect/user_tokens/refresh_service.rb b/modules/openid_connect/app/services/openid_connect/user_tokens/refresh_service.rb new file mode 100644 index 000000000000..8d73536d6d68 --- /dev/null +++ b/modules/openid_connect/app/services/openid_connect/user_tokens/refresh_service.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenIDConnect + module UserTokens + class RefreshService + include Dry::Monads[:result] + include Dry::Monads::Do.for(:call) + + def initialize(user:, token_exchange:) + @user = user + @token_exchange = token_exchange + end + + def call(token) + if token.refresh_token.blank? + return exchange_instead_of_refresh(token) + end + + json = yield refresh_token_request(token.refresh_token) + + access_token = json["access_token"] + refresh_token = json["refresh_token"] + return Failure("Refresh token response invalid") if access_token.blank? + + token.update!(access_token:, refresh_token:) + + Success(token) + end + + private + + def exchange_instead_of_refresh(token) + # We can attempt a token exchange instead of a refresh, if we previously exchanged the token. + # For simplicity we do not consider scenarios where the original token had a wider audience, + # because all tokens obtained through exchange in this service will have exactly one audience. + if @token_exchange.supported? && token.audiences.size == 1 + return @token_exchange.call(token.audiences.first) + end + + Failure("Can't refresh the access token") + end + + def refresh_token_request(refresh_token) + response = OpenProject.httpx + .basic_auth(provider.client_id, provider.client_secret) + .post(provider.token_endpoint, form: { + grant_type: :refresh_token, + refresh_token: + }) + response.raise_for_status + + Success(response.json) + rescue HTTPX::Error => e + Failure(e) + end + + def provider + @user.authentication_provider + end + end + end +end diff --git a/modules/openid_connect/spec/factories/oidc_provider_factory.rb b/modules/openid_connect/spec/factories/oidc_provider_factory.rb index 069df379891c..ed28a8d6a5c8 100644 --- a/modules/openid_connect/spec/factories/oidc_provider_factory.rb +++ b/modules/openid_connect/spec/factories/oidc_provider_factory.rb @@ -18,6 +18,13 @@ "authorization_endpoint" => "https://keycloak.local/realms/master/protocol/openid-connect/auth" } end + + trait :token_exchange_capable do + callback(:after_build) do |provider| + provider.options["grant_types_supported"] ||= [] + provider.options["grant_types_supported"] << "urn:ietf:params:oauth:grant-type:token-exchange" + end + end end factory :oidc_provider_google, class: "OpenIDConnect::Provider" do diff --git a/modules/openid_connect/spec/services/openid_connect/user_tokens/exchange_service_spec.rb b/modules/openid_connect/spec/services/openid_connect/user_tokens/exchange_service_spec.rb new file mode 100644 index 000000000000..cf8ebc9b2635 --- /dev/null +++ b/modules/openid_connect/spec/services/openid_connect/user_tokens/exchange_service_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "spec_helper" + +RSpec.describe OpenIDConnect::UserTokens::ExchangeService, :webmock do + let(:service) { described_class.new(user:) } + let(:user) { create(:user, identity_url: "#{provider.slug}:1337") } + let(:provider) { create(:oidc_provider, :token_exchange_capable) } + + let(:access_token) { "the-access-token" } + let(:refresh_token) { "the-refresh-token" } + let(:idp_access_token) { "the-idp-access-token" } + + let(:existing_audience) { "existing-audience" } + + let(:exchange_response) do + { + status: 200, + headers: { "Content-Type": "application/json" }, + body: { access_token: "#{access_token}-exchanged", refresh_token: "#{refresh_token}-exchanged" }.to_json + } + end + + before do + user.oidc_user_tokens.create!(access_token: idp_access_token, audiences: [OpenIDConnect::UserToken::IDP_AUDIENCE]) + user.oidc_user_tokens.create!(access_token:, refresh_token:, audiences: [existing_audience]) + stub_request(:post, provider.token_endpoint) + .with(body: hash_including(grant_type: "urn:ietf:params:oauth:grant-type:token-exchange")) + .to_return(**exchange_response) + end + + describe "#call" do + subject(:result) { service.call(audience) } + + let(:audience) { "new-audience" } + + it { is_expected.to be_success } + + it "creates a new user token", :aggregate_failures do + expect { subject }.to change(user.oidc_user_tokens, :count).from(2).to(3) + expect(user.oidc_user_tokens.last.access_token).to eq("the-access-token-exchanged") + expect(user.oidc_user_tokens.last.refresh_token).to eq("the-refresh-token-exchanged") + end + + it "returns the new user token" do + expect(result.value!).to eq(user.oidc_user_tokens.last) + end + + it "used the IDP access token to perform the exchange" do + subject + expect(WebMock).to have_requested(:post, provider.token_endpoint) + .with(body: hash_including(subject_token: idp_access_token)) + end + + context "when exchanging token for an existing user token" do + let(:audience) { existing_audience } + + it { is_expected.to be_success } + + it "creates a new user token", :aggregate_failures do + expect { subject }.not_to change(user.oidc_user_tokens, :count) + expect(user.oidc_user_tokens.last.access_token).to eq("the-access-token-exchanged") + expect(user.oidc_user_tokens.last.refresh_token).to eq("the-refresh-token-exchanged") + end + + it "returns the updated user token" do + expect(result.value!).to eq(user.oidc_user_tokens.last) + end + end + + context "when provider is not capable of token exchange" do + let(:provider) { create(:oidc_provider) } + + it { is_expected.to be_failure } + end + end + + describe "#supported?" do + subject { service.supported? } + + it { is_expected.to be_truthy } + + context "when provider is not capable of token exchange" do + let(:provider) { create(:oidc_provider) } + + it { is_expected.to be_falsey } + end + end +end diff --git a/modules/openid_connect/spec/services/openid_connect/user_tokens/fetch_service_spec.rb b/modules/openid_connect/spec/services/openid_connect/user_tokens/fetch_service_spec.rb index 3823db6b8a95..47e5f90cd392 100644 --- a/modules/openid_connect/spec/services/openid_connect/user_tokens/fetch_service_spec.rb +++ b/modules/openid_connect/spec/services/openid_connect/user_tokens/fetch_service_spec.rb @@ -30,68 +30,30 @@ require "spec_helper" RSpec.describe OpenIDConnect::UserTokens::FetchService, :webmock do - let(:service) { described_class.new(user:, jwt_parser:) } - let(:user) { create(:user, identity_url: "#{provider.slug}:1337") } - let(:provider) { create(:oidc_provider) } + let(:service) { described_class.new(user:, jwt_parser:, token_exchange:, token_refresh:) } + let(:user) { create(:user) } let(:jwt_parser) { instance_double(OpenIDConnect::JwtParser, parse: Success([parsed_jwt, nil])) } let(:parsed_jwt) { { "exp" => Time.now.to_i + 60 } } + let(:token_exchange) { instance_double(OpenIDConnect::UserTokens::ExchangeService, supported?: false) } + let(:token_refresh) { instance_double(OpenIDConnect::UserTokens::RefreshService) } + let(:access_token) { "the-access-token" } let(:refresh_token) { "the-refresh-token" } let(:existing_audience) { "existing-audience" } let(:queried_audience) { existing_audience } - let(:refresh_response) do - { - status: 200, - headers: { "Content-Type": "application/json" }, - body: { access_token: "#{access_token}-refreshed", refresh_token: "#{refresh_token}-refreshed" }.to_json - } - end - before do user.oidc_user_tokens.create!(access_token:, refresh_token:, audiences: [existing_audience]) - stub_request(:post, provider.token_endpoint).to_return(**refresh_response) - end - - shared_examples_for "returns a refreshed access token" do - it { is_expected.to be_success } - - it "returns a refreshed access token" do - expect(result.value!).to eq("the-access-token-refreshed") - end - - it "updates the stored access token" do - expect { subject }.to change { user.oidc_user_tokens.first.access_token }.to("the-access-token-refreshed") + allow(token_refresh).to receive(:call) do |token| + token.update!(access_token: "access-token-refreshed", refresh_token: "refresh-token-refreshed") + Success(token) end - - it "updates the stored refresh token" do - expect { subject }.to change { user.oidc_user_tokens.first.refresh_token }.to("the-refresh-token-refreshed") - end - - context "when the refresh response is unexpected JSON" do - let(:refresh_response) do - { - status: 200, # misbehaving server responds with wrong JSON for success status - headers: { "Content-Type": "application/json" }, - body: { error: "I can't let you do that Dave!" }.to_json - } - end - - it { is_expected.to be_failure } - end - - context "when the refresh response has unexpected status" do - let(:refresh_response) do - { - status: 502, - headers: { "Content-Type": "text/html" }, - body: "502 Bad Gateway" - } - end - - it { is_expected.to be_failure } + allow(token_exchange).to receive(:call) do |aud| + Success(user.oidc_user_tokens.create!(access_token: "access-token-exchanged", + refresh_token: "refresh-token-exchanged", + audiences: [aud])) end end @@ -117,17 +79,10 @@ context "when the token is expired" do let(:parsed_jwt) { { "exp" => Time.now.to_i } } - it_behaves_like "returns a refreshed access token" - - context "and there is no refresh token" do - let(:refresh_token) { nil } - - it { is_expected.to be_failure } + it { is_expected.to be_success } - it "does not try to perform a token refresh" do - subject - expect(WebMock).not_to have_requested(:post, provider.token_endpoint) - end + it "returns the refreshed access token" do + expect(result.value!).to eq("access-token-refreshed") end end @@ -135,29 +90,42 @@ let(:queried_audience) { "wrong-audience" } it { is_expected.to be_failure } + + it "does not attempt a token exchange" do + subject + expect(token_exchange).not_to have_received(:call) + end + + context "and the provider is token exchange capable" do + let(:token_exchange) { instance_double(OpenIDConnect::UserTokens::ExchangeService, supported?: true) } + + it { is_expected.to be_success } + + it "returns the exchanged access token" do + expect(result.value!).to eq("access-token-exchanged") + end + + it "tries to exchange the correct audience" do + subject + expect(token_exchange).to have_received(:call).with(queried_audience) + end + end end end describe "#refreshed_access_token_for" do subject(:result) { service.refreshed_access_token_for(audience: queried_audience) } - it_behaves_like "returns a refreshed access token" - - context "when audience can't be found" do - let(:queried_audience) { "wrong-audience" } + it { is_expected.to be_success } - it { is_expected.to be_failure } + it "returns the refreshed access token" do + expect(result.value!).to eq("access-token-refreshed") end - context "and there is no refresh token" do - let(:refresh_token) { nil } + context "when audience can't be found" do + let(:queried_audience) { "wrong-audience" } it { is_expected.to be_failure } - - it "does not try to perform a token refresh" do - subject - expect(WebMock).not_to have_requested(:post, provider.token_endpoint) - end end end end diff --git a/modules/openid_connect/spec/services/openid_connect/user_tokens/refresh_service_spec.rb b/modules/openid_connect/spec/services/openid_connect/user_tokens/refresh_service_spec.rb new file mode 100644 index 000000000000..22e9124af2af --- /dev/null +++ b/modules/openid_connect/spec/services/openid_connect/user_tokens/refresh_service_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +require "spec_helper" + +RSpec.describe OpenIDConnect::UserTokens::RefreshService, :webmock do + subject(:result) { service.call(token) } + + let(:service) { described_class.new(user:, token_exchange:) } + let(:user) { create(:user, identity_url: "#{provider.slug}:1337") } + let(:provider) { create(:oidc_provider) } + + let(:token_exchange) do + instance_double(OpenIDConnect::UserTokens::ExchangeService, supported?: false) + end + + let(:token) do + user.oidc_user_tokens.create!(access_token: "a-token", refresh_token: "r-token", audiences: ["the-audience"]) + end + + let(:refresh_response) do + { + status: 200, + headers: { "Content-Type": "application/json" }, + body: { access_token: "a-refreshed", refresh_token: "r-refreshed" }.to_json + } + end + + before do + stub_request(:post, provider.token_endpoint) + .with(body: hash_including(grant_type: "refresh_token")) + .to_return(**refresh_response) + token.save! + end + + it { is_expected.to be_success } + + it "returns the updated token" do + expect(result.value!).to eq(user.oidc_user_tokens.first) + end + + it "updates the stored access token" do + expect { subject }.to change { user.oidc_user_tokens.first.access_token }.from("a-token").to("a-refreshed") + end + + it "updates the stored refresh token" do + expect { subject }.to change { user.oidc_user_tokens.first.refresh_token }.from("r-token").to("r-refreshed") + end + + context "when the refresh response is unexpected JSON" do + let(:refresh_response) do + { + status: 200, # misbehaving server responds with wrong JSON for success status + headers: { "Content-Type": "application/json" }, + body: { error: "I can't let you do that Dave!" }.to_json + } + end + + it { is_expected.to be_failure } + end + + context "when the refresh response has unexpected status" do + let(:refresh_response) do + { + status: 502, + headers: { "Content-Type": "text/html" }, + body: "502 Bad Gateway" + } + end + + it { is_expected.to be_failure } + end + + context "when there is no refresh token" do + let(:token) do + user.oidc_user_tokens.create!(access_token: "a-token", refresh_token: nil, audiences: ["the-audience"]) + end + + it { is_expected.to be_failure } + + it "does not try to perform a token refresh" do + subject + expect(WebMock).not_to have_requested(:post, provider.token_endpoint) + .with(body: hash_including(grant_type: "refresh_token")) + end + + context "and the provider is token exchange capable" do + let(:token_exchange) do + instance_double(OpenIDConnect::UserTokens::ExchangeService, supported?: true, call: Success("exchange-result")) + end + + it { is_expected.to be_success } + + it "returns the exchanged access token" do + expect(result.value!).to eq("exchange-result") + end + + it "tries to exchange for the token's audience" do + subject + expect(token_exchange).to have_received(:call).with("the-audience") + end + end + end +end