Skip to content

Commit

Permalink
Save OIDC tokens to OpenProject database.
Browse files Browse the repository at this point in the history
Storing tokens in the database to have them available for
requests to third parties (e.g. Nextcloud) later.

The OIDC session is now marked as optional, since the
session link is also used to store access and refresh tokens
associated with the session. Those tokens might be present,
even if the session id (which belongs to the optional
OIDC Back-Channel Logout specification) is missing.
  • Loading branch information
NobodysNightmare committed Dec 13, 2024
1 parent c2fd334 commit 567b1d7
Show file tree
Hide file tree
Showing 11 changed files with 253 additions and 39 deletions.
6 changes: 3 additions & 3 deletions app/services/authentication/omniauth_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def call(additional_user_params = nil)

# Create or update the user from omniauth
# and assign non-nil parameters from the registration form - if any
assignable_params = (additional_user_params || {}).reject { |_, v| v.nil? }
assignable_params = (additional_user_params || {}).compact
update_user_from_omniauth!(assignable_params)

# If we have a new or invited user, we still need to register them
Expand Down Expand Up @@ -165,7 +165,7 @@ def find_existing_user
def remap_existing_user
return unless Setting.oauth_allow_remapping_of_existing_users?

User.not_builtin.find_by_login(user_attributes[:login]) # rubocop:disable Rails/DynamicFindBy
User.not_builtin.find_by_login(user_attributes[:login])
end

##
Expand Down Expand Up @@ -285,7 +285,7 @@ def identity_url_from_omniauth
# Try to provide some context of the auth_hash in case of errors
def auth_uid
hash = auth_hash || {}
hash.dig(:info, :uid) || hash.dig(:uid) || "unknown"
hash.dig(:info, :uid) || hash[:uid] || "unknown"
end
end
end
Original file line number Diff line number Diff line change
@@ -1,3 +1,31 @@
#-- 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
class UserSessionLink < ::ApplicationRecord
self.table_name = "oidc_user_session_links"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#-- 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.
#++

class AddTokensToOidcUserSessionLinks < ActiveRecord::Migration[7.1]
def change
add_column :oidc_user_session_links, :access_token, :string
add_column :oidc_user_session_links, :refresh_token, :string
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#-- 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.
#++

class MakeOidcSessionOptional < ActiveRecord::Migration[7.1]
def change
change_column_null :oidc_user_session_links, :oidc_session, true
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class Engine < ::Rails::Engine
openid_connect/auth_provider-custom.png
)

patches %i[Sessions::UserSession]

class_inflection_override("openid_connect" => "OpenIDConnect")

register_auth_providers do
Expand All @@ -49,7 +51,11 @@ class Engine < ::Rails::Engine
end

# Remember oidc session values when logging in user
h[:retain_from_session] = %w[omniauth.oidc_sid]
h[:retain_from_session] = %w[
omniauth.oidc_sid
omniauth.oidc_access_token
omniauth.oidc_refresh_token
]

h[:backchannel_logout_callback] = ->(logout_token) do
::OpenProject::OpenIDConnect::SessionMapper.handle_logout(logout_token)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,21 @@ class Hook < OpenProject::Hook::Listener
# Once the user has signed in and has an oidc session
# we want to map that to the internal session
def user_logged_in(context)
session = context[:session]
oidc_sid = session["omniauth.oidc_sid"]
return if oidc_sid.nil?

::OpenProject::OpenIDConnect::SessionMapper.handle_login(oidc_sid, session)
session = context.fetch(:session)
::OpenProject::OpenIDConnect::SessionMapper.handle_login(session)
end

##
# Called once omniauth has returned with an auth hash
# NOTE: It's a passthrough as we no longer persist the access token into the cookie
def omniauth_user_authorized(_context); end
def omniauth_user_authorized(context)
controller = context.fetch(:controller)
session = controller.session

session["omniauth.oidc_access_token"] = context.dig(:auth_hash, :credentials, :token)
session["omniauth.oidc_refresh_token"] = context.dig(:auth_hash, :credentials, :refresh_token)

nil
end
end
end
end
30 changes: 30 additions & 0 deletions modules/openid_connect/lib/open_project/openid_connect/patches.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#-- 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.
#++

module OpenProject::OpenIDConnect::Patches
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#-- 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.
#++

module OpenProject::OpenIDConnect::Patches::Sessions::UserSessionPatch
def self.included(base) # :nodoc:
base.extend(ClassMethods)
base.include(InstanceMethods)

base.class_eval do
has_one :oidc_session_link, class_name: "OpenIDConnect::UserSessionLink", foreign_key: "session_id"
end
end

module ClassMethods
end

module InstanceMethods
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,10 @@ def self.handle_logout(logout_token)
raise e
end

def self.handle_login(oidc_session, session)
if oidc_session.blank?
Rails.logger.info { "No OIDC session returned from provider. Cannot map session for later logouts." }
return
end

link = ::OpenIDConnect::UserSessionLink.new(oidc_session:)
def self.handle_login(session)
link = ::OpenIDConnect::UserSessionLink.new(oidc_session: session["omniauth.oidc_sid"],
access_token: session["omniauth.oidc_access_token"],
refresh_token: session["omniauth.oidc_refresh_token"])
new(link).link_to_internal!(session)
rescue StandardError => e
Rails.logger.error { "Failed to map OIDC session to internal: #{e.message}" }
Expand Down
51 changes: 39 additions & 12 deletions modules/openid_connect/spec/lib/session_mapper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,31 +28,58 @@
require "spec_helper"

RSpec.describe OpenProject::OpenIDConnect::SessionMapper do
let(:mock_session) do
Class.new(Rack::Session::Abstract::SessionHash) do
def initialize(id)
super(nil, nil)
@id = Rack::Session::SessionId.new(id)
@data = {}
@loaded = true
end
end
let(:session) do
instance_double(ActionDispatch::Request::Session,
id: instance_double(Rack::Session::SessionId, private_id: 42))
end

let(:session_data) do
{
"omniauth.oidc_sid" => oidc_session_id,
"omniauth.oidc_access_token" => access_token,
"omniauth.oidc_refresh_token" => refresh_token
}
end

let(:oidc_session_id) { "oidc_sid_foo" }
let(:access_token) { "access_token_bar" }
let(:refresh_token) { "refresh_token_baz" }

before do
allow(session).to receive(:[]) { |k| session_data[k] }
end

describe "handle_login" do
let(:session) { mock_session.new("foo") }
let!(:plain_session) { create(:user_session, session_id: session.id.private_id) }
let!(:user_session) { Sessions::UserSession.find_by(session_id: plain_session.session_id) }

subject { described_class.handle_login "oidc_sid_foo", session }
subject { described_class.handle_login session }

it "creates a user link object" do
expect { subject }.to change(OpenIDConnect::UserSessionLink, :count).by(1)
link = OpenIDConnect::UserSessionLink.find_by(session_id: user_session.id)

expect(link).to be_present
expect(link.session).to eq user_session
expect(link.oidc_session).to eq "oidc_sid_foo"
expect(link.access_token).to eq access_token
expect(link.oidc_session).to eq oidc_session_id
expect(link.refresh_token).to eq refresh_token
end

context "when there is only an access token" do
let(:oidc_session_id) { nil }
let(:refresh_token) { nil }

it "creates a user link object" do
expect { subject }.to change(OpenIDConnect::UserSessionLink, :count).by(1)
link = OpenIDConnect::UserSessionLink.find_by(session_id: user_session.id)

expect(link).to be_present
expect(link.session).to eq user_session
expect(link.access_token).to eq access_token
expect(link.oidc_session).to be_nil
expect(link.refresh_token).to be_nil
end
end
end

Expand Down
29 changes: 20 additions & 9 deletions modules/openid_connect/spec/requests/openid_connect_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,23 +46,26 @@
family_name: "Wurst"
}
end
let(:access_token) { "foo-bar-baz" }
let(:refresh_token) { "refreshing-foo-bar-baz" }
let(:oidc_sid) { "oidc-session-id-42" }

before do
# The redirect will include an authorisation code.
# Since we don't actually get a valid code in the test we will stub the resulting AccessToken.
allow_any_instance_of(OpenIDConnect::Client).to receive(:access_token!) do
OpenIDConnect::AccessToken.new client: self, access_token: "foo bar baz"
instance_double(OpenIDConnect::AccessToken,
access_token:,
refresh_token:,
userinfo!: OpenIDConnect::ResponseObject::UserInfo.new(user_info),
id_token: "not-nil").as_null_object
end

# Using the granted AccessToken the client then performs another request to the OpenID Connect
# provider to retrieve user information such as name and email address.
# Since the test is not supposed to make an actual call it is be stubbed too.
allow_any_instance_of(OpenIDConnect::AccessToken).to receive(:userinfo!).and_return(
OpenIDConnect::ResponseObject::UserInfo.new(user_info)
# We are also stubbing the way that an ID token would be decoded, so that the omniauth-openid-connect
# strategy can fill the session id as well
allow(OpenIDConnect::ResponseObject::IdToken).to receive(:decode).and_return(
instance_double(OpenIDConnect::ResponseObject::IdToken, sid: oidc_sid).as_null_object
)

# Enable storing the access token in a cookie is not necessary since it is currently hard wired to always
# be true.
end

describe "sign-up and login" do
Expand Down Expand Up @@ -95,6 +98,14 @@
expect(user).not_to be_nil
expect(user.active?).to be true

session = Sessions::UserSession.for_user(user).first
session_link = session.oidc_session_link

expect(session_link).not_to be_nil
expect(session_link.oidc_session).to eq oidc_sid
expect(session_link.access_token).to eq access_token
expect(session_link.refresh_token).to eq refresh_token

##
# it should redirect to the provider again upon clicking on sign-in when the user has been activated
user = User.find_by(mail: user_info[:email])
Expand Down

0 comments on commit 567b1d7

Please sign in to comment.