diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index a3af49bfd11..da54a8fc6da 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1732,7 +1732,9 @@ en: google_oauth2_client_secret: "Client secret of your Google application." google_oauth2_prompt: "An optional space-delimited list of string values that specifies whether the authorization server prompts the user for reauthentication and consent. See https://developers.google.com/identity/protocols/OpenIDConnect#prompt for the possible values." google_oauth2_hd: "An optional Google Apps Hosted domain that the sign-in will be limited to. See https://developers.google.com/identity/protocols/OpenIDConnect#hd-param for more details." - google_oauth2_hd_groups: "(experimental) Retrieve users' Google groups on the hosted domain on authentication. Retrieved Google groups can be used to grant automatic Discourse group membership (see group settings)." + google_oauth2_hd_groups: "(experimental) Retrieve users' Google groups on the hosted domain on authentication. Retrieved Google groups can be used to grant automatic Discourse group membership (see group settings). For more information see https://meta.discourse.org/t/226850" + google_oauth2_hd_groups_service_account_admin_email: "An email address belonging to a Google Workspace administrator account. Will be used with the service account credentials to fetch group information." + google_oauth2_hd_groups_service_account_json: "JSON formatted key information for the Service Account. Will be used to fetch group information." enable_twitter_logins: "Enable Twitter authentication, requires twitter_consumer_key and twitter_consumer_secret. See Configuring Twitter login (and rich embeds) for Discourse." twitter_consumer_key: "Consumer key for Twitter authentication, registered at https://developer.twitter.com/apps" @@ -2427,7 +2429,7 @@ en: unicode_usernames_avatars: "The internal system avatars do not support Unicode usernames." list_value_count: "The list must contain exactly %{count} values." markdown_linkify_tlds: "You cannot include a value of '*'." - google_oauth2_hd_groups: "You must first set 'google oauth2 hd' before enabling this setting." + google_oauth2_hd_groups: "You must configure all 'google oauth2 hd' settings before enabling this setting." search_tokenize_chinese_enabled: "You must disable 'search_tokenize_chinese' before enabling this setting." search_tokenize_japanese_enabled: "You must disable 'search_tokenize_japanese' before enabling this setting." discourse_connect_cannot_be_enabled_if_second_factor_enforced: "You cannot enable DiscourseConnect if 2FA is enforced." diff --git a/config/site_settings.yml b/config/site_settings.yml index 43100b93b6b..7d6f03510cc 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -429,6 +429,11 @@ login: google_oauth2_hd_groups: default: false validator: GoogleOauth2HdGroupsValidator + google_oauth2_hd_groups_service_account_admin_email: + default: "" + google_oauth2_hd_groups_service_account_json: + default: "" + textarea: true enable_twitter_logins: default: false twitter_consumer_key: diff --git a/lib/auth.rb b/lib/auth.rb index 21e2716250f..f501d901574 100644 --- a/lib/auth.rb +++ b/lib/auth.rb @@ -6,7 +6,6 @@ require 'auth/auth_provider' require 'auth/result' require 'auth/authenticator' require 'auth/managed_authenticator' -require 'auth/omniauth_strategies/discourse_google_oauth2' require 'auth/facebook_authenticator' require 'auth/github_authenticator' require 'auth/twitter_authenticator' diff --git a/lib/auth/google_oauth2_authenticator.rb b/lib/auth/google_oauth2_authenticator.rb index 02e015cd10c..ba35b1fecd3 100644 --- a/lib/auth/google_oauth2_authenticator.rb +++ b/lib/auth/google_oauth2_authenticator.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true class Auth::GoogleOAuth2Authenticator < Auth::ManagedAuthenticator + GROUPS_SCOPE ||= "https://www.googleapis.com/auth/admin.directory.group.readonly" + GROUPS_DOMAIN ||= "admin.googleapis.com" + GROUPS_PATH ||= "/admin/directory/v1/groups" + OAUTH2_BASE_URL ||= "https://oauth2.googleapis.com" + def name "google_oauth2" end @@ -16,7 +21,6 @@ class Auth::GoogleOAuth2Authenticator < Auth::ManagedAuthenticator end def register_middleware(omniauth) - strategy_class = Auth::OmniAuthStrategies::DiscourseGoogleOauth2 options = { setup: lambda { |env| strategy = env["omniauth.strategy"] @@ -36,25 +40,96 @@ class Auth::GoogleOAuth2Authenticator < Auth::ManagedAuthenticator # the JWT can fail due to clock skew, so let's skip it completely. # https://github.com/zquestz/omniauth-google-oauth2/pull/392 strategy.options[:skip_jwt] = true - strategy.options[:request_groups] = provides_groups? - - if provides_groups? - strategy.options[:scope] = "#{strategy_class::DEFAULT_SCOPE},#{strategy_class::GROUPS_SCOPE}" - end } } - omniauth.provider strategy_class, options + omniauth.provider :google_oauth2, options end def after_authenticate(auth_token, existing_account: nil) - result = super - if provides_groups? && (groups = auth_token[:extra][:raw_groups]) - result.associated_groups = groups.map { |group| group.slice(:id, :name) } + groups = provides_groups? ? raw_groups(auth_token.uid) : nil + if groups + auth_token.extra[:raw_groups] = groups end + + result = super + + if groups + result.associated_groups = groups.map { |group| group.with_indifferent_access.slice(:id, :name) } + end + result end def provides_groups? - SiteSetting.google_oauth2_hd.present? && SiteSetting.google_oauth2_hd_groups + SiteSetting.google_oauth2_hd.present? && + SiteSetting.google_oauth2_hd_groups && + SiteSetting.google_oauth2_hd_groups_service_account_admin_email.present? && + SiteSetting.google_oauth2_hd_groups_service_account_json.present? + end + + private + + def raw_groups(uid) + groups = [] + page_token = nil + groups_url = "https://#{GROUPS_DOMAIN}#{GROUPS_PATH}" + client = build_service_account_client + return if client.nil? + + loop do + params = { + userKey: uid + } + params[:pageToken] = page_token if page_token + + response = client.get(groups_url, params: params, raise_errors: false) + + if response.status == 200 + response = response.parsed + groups.push(*response['groups']) + page_token = response['nextPageToken'] + break if page_token.nil? + else + Rails.logger.error("[Discourse Google OAuth2] failed to retrieve groups for #{uid} - status #{response.status}") + break + end + end + + groups + end + + def build_service_account_client + service_account_info = JSON.parse(SiteSetting.google_oauth2_hd_groups_service_account_json) + + payload = { + iss: service_account_info["client_email"], + aud: "#{OAUTH2_BASE_URL}/token", + scope: GROUPS_SCOPE, + iat: Time.now.to_i, + exp: Time.now.to_i + 60, + sub: SiteSetting.google_oauth2_hd_groups_service_account_admin_email + } + headers = { "alg" => "RS256", "typ" => "JWT" } + key = OpenSSL::PKey::RSA.new(service_account_info["private_key"]) + + encoded_jwt = ::JWT.encode(payload, key, 'RS256', headers) + + client = OAuth2::Client.new( + SiteSetting.google_oauth2_client_id, + SiteSetting.google_oauth2_client_secret, + site: OAUTH2_BASE_URL + ) + + token_response = client.request(:post, '/token', body: { + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: encoded_jwt + }, raise_errors: false) + + if token_response.status != 200 + Rails.logger.error("[Discourse Google OAuth2] failed to retrieve group fetch token - status #{token_response.status}") + return + end + + OAuth2::AccessToken.from_hash(client, token_response.parsed) end end diff --git a/lib/auth/omniauth_strategies/discourse_google_oauth2.rb b/lib/auth/omniauth_strategies/discourse_google_oauth2.rb deleted file mode 100644 index 326e26d81be..00000000000 --- a/lib/auth/omniauth_strategies/discourse_google_oauth2.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -class Auth::OmniAuthStrategies - class DiscourseGoogleOauth2 < OmniAuth::Strategies::GoogleOauth2 - GROUPS_SCOPE ||= "admin.directory.group.readonly" - GROUPS_DOMAIN ||= "admin.googleapis.com" - GROUPS_PATH ||= "/admin/directory/v1/groups" - - def extra - hash = {} - hash[:raw_info] = raw_info - hash[:raw_groups] = raw_groups if options[:request_groups] - hash - end - - def raw_groups - @raw_groups ||= begin - groups = [] - page_token = nil - groups_url = "https://#{GROUPS_DOMAIN}#{GROUPS_PATH}" - - loop do - params = { - userKey: uid - } - params[:pageToken] = page_token if page_token - - response = access_token.get(groups_url, params: params, raise_errors: false) - - if response.status == 200 - response = response.parsed - groups.push(*response['groups']) - page_token = response['nextPageToken'] - break if page_token.nil? - else - Rails.logger.error("[Discourse Google OAuth2] failed to retrieve groups for #{uid} - status #{response.status}") - break - end - end - - groups - end - end - end -end diff --git a/lib/validators/google_oauth2_hd_groups_validator.rb b/lib/validators/google_oauth2_hd_groups_validator.rb index b4c3f91431e..39acf6b9bf7 100644 --- a/lib/validators/google_oauth2_hd_groups_validator.rb +++ b/lib/validators/google_oauth2_hd_groups_validator.rb @@ -6,7 +6,7 @@ class GoogleOauth2HdGroupsValidator end def valid_value?(value) - @valid = value == "f" || SiteSetting.google_oauth2_hd.present? + @valid = value == "f" || (SiteSetting.google_oauth2_hd.present? && SiteSetting.google_oauth2_hd_groups_service_account_admin_email.present? && SiteSetting.google_oauth2_hd_groups_service_account_json.present?) end def error_message diff --git a/spec/lib/auth/google_oauth2_authenticator_spec.rb b/spec/lib/auth/google_oauth2_authenticator_spec.rb index 661ef1f25c5..f610763ed35 100644 --- a/spec/lib/auth/google_oauth2_authenticator_spec.rb +++ b/spec/lib/auth/google_oauth2_authenticator_spec.rb @@ -117,7 +117,7 @@ RSpec.describe Auth::GoogleOAuth2Authenticator do group1 = OmniAuth::AuthHash.new(id: "12345", name: "group1") group2 = OmniAuth::AuthHash.new(id: "67890", name: "group2") @groups = [group1, group2] - @groups_hash = OmniAuth::AuthHash.new( + @auth_hash = OmniAuth::AuthHash.new( provider: "google_oauth2", uid: "123456789", info: { @@ -132,26 +132,86 @@ RSpec.describe Auth::GoogleOAuth2Authenticator do email_verified: true, name: "Jane Doe" }, - raw_groups: @groups } ) end context "when enabled" do + let(:private_key) { OpenSSL::PKey::RSA.generate(2048) } + + let(:group_response) { + { + groups: [ + { + id: "12345", + name: "group1" + }, + { + id: "67890", + name: "group2" + } + ] + } + } + before do + SiteSetting.google_oauth2_hd_groups_service_account_admin_email = "admin@example.com" + SiteSetting.google_oauth2_hd_groups_service_account_json = { + "private_key" => private_key.to_s, + "client_email": "discourse-group-sync@example.iam.gserviceaccount.com", + }.to_json SiteSetting.google_oauth2_hd_groups = true + + token = "abcde" + + stub_request(:post, "https://oauth2.googleapis.com/token").to_return do |request| + jwt = Rack::Utils.parse_query(request.body)["assertion"] + decoded_token = JWT.decode(jwt, private_key.public_key, true, { algorithm: 'RS256' }) + { + status: 200, + body: { "access_token" => token, "type" => "bearer" }.to_json, + headers: { "Content-Type" => "application/json" } + } + rescue JWT::VerificationError + { + status: 403, + body: "Invalid JWT" + } + end + + stub_request(:get, "https://admin.googleapis.com/admin/directory/v1/groups?userKey=#{@auth_hash.uid}"). + with(headers: { "Authorization" => "Bearer #{token}" }). + to_return do + { + status: 200, + body: group_response.to_json, + headers: { + "Content-Type" => "application/json" + } + } + end end it "adds associated groups" do - result = described_class.new.after_authenticate(@groups_hash) + result = described_class.new.after_authenticate(@auth_hash) expect(result.associated_groups).to eq(@groups) end it "handles a blank groups array" do - @groups_hash[:extra][:raw_groups] = [] - result = described_class.new.after_authenticate(@groups_hash) + group_response[:groups] = [] + result = described_class.new.after_authenticate(@auth_hash) expect(result.associated_groups).to eq([]) end + + it "doesn't explode with invalid credentials" do + SiteSetting.google_oauth2_hd_groups_service_account_json = { + "private_key" => OpenSSL::PKey::RSA.generate(2048).to_s, + "client_email": "discourse-group-sync@example.iam.gserviceaccount.com", + }.to_json + + result = described_class.new.after_authenticate(@auth_hash) + expect(result.associated_groups).to eq(nil) + end end context "when disabled" do @@ -160,7 +220,7 @@ RSpec.describe Auth::GoogleOAuth2Authenticator do end it "doesnt add associated groups" do - result = described_class.new.after_authenticate(@groups_hash) + result = described_class.new.after_authenticate(@auth_hash) expect(result.associated_groups).to eq(nil) end end diff --git a/spec/lib/auth/omniauth_strategies/discourse_google_oauth2_spec.rb b/spec/lib/auth/omniauth_strategies/discourse_google_oauth2_spec.rb deleted file mode 100644 index 37229ecae44..00000000000 --- a/spec/lib/auth/omniauth_strategies/discourse_google_oauth2_spec.rb +++ /dev/null @@ -1,124 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Auth::OmniAuthStrategies::DiscourseGoogleOauth2 do - let(:response_hash) do - { - email: 'user@domain.com', - email_verified: true - } - end - let(:groups) do - [ - { - id: "12345", - name: "group1" - }, - { - id: "67890", - name: "group2" - } - ] - end - let(:uid) { "12345" } - let(:domain) { "domain.com" } - - def build_response(body, code = 200) - [code, { 'Content-Type' => 'application/json' }, body.to_json] - end - - def build_client(groups_response) - OAuth2::Client.new('abc', 'def') do |builder| - builder.request :url_encoded - builder.adapter :test do |stub| - stub.get('/oauth2/v3/userinfo') { build_response(response_hash) } - stub.get(described_class::GROUPS_PATH) { groups_response } - end - end - end - - let(:successful_groups_client) do - build_client( - build_response( - groups: groups - ) - ) - end - - let(:unsuccessful_groups_client) do - build_client( - build_response( - error: { - code: 403, - message: "Not Authorized to access this resource/api" - } - ) - ) - end - - let(:successful_groups_token) do - OAuth2::AccessToken.from_hash(successful_groups_client, {}) - end - - let(:unsuccessful_groups_token) do - OAuth2::AccessToken.from_hash(unsuccessful_groups_client, {}) - end - - def app - lambda do |_env| - [200, {}, ["Hello."]] - end - end - - def build_strategy(access_token) - strategy = described_class.new(app, 'appid', 'secret', @options) - strategy.stubs(:uid).returns(uid) - strategy.stubs(:access_token).returns(access_token) - strategy - end - - before do - @options = {} - OmniAuth.config.test_mode = true - end - - after do - OmniAuth.config.test_mode = false - end - - context 'when request_groups is true' do - before do - @options[:request_groups] = true - end - - context 'when groups request successful' do - before do - @strategy = build_strategy(successful_groups_token) - end - - it 'should include users groups' do - expect(@strategy.extra[:raw_groups].map(&:symbolize_keys)).to eq(groups) - end - end - - context 'when groups request unsuccessful' do - before do - @strategy = build_strategy(unsuccessful_groups_token) - end - - it 'users groups should be empty' do - expect(@strategy.extra[:raw_groups].empty?).to eq(true) - end - end - end - - context 'when request_groups is not true' do - before do - @options[:request_groups] = false - @strategy = build_strategy(successful_groups_token) - end - - it 'should not include users groups' do - expect(@strategy.extra).not_to have_key(:raw_groups) - end - end -end diff --git a/spec/models/associated_group_spec.rb b/spec/models/associated_group_spec.rb index 55f0ec6cb5d..916927504a4 100644 --- a/spec/models/associated_group_spec.rb +++ b/spec/models/associated_group_spec.rb @@ -14,6 +14,8 @@ RSpec.describe AssociatedGroup do SiteSetting.enable_google_oauth2_logins = true SiteSetting.google_oauth2_hd = 'domain.com' SiteSetting.google_oauth2_hd_groups = false + SiteSetting.google_oauth2_hd_groups_service_account_admin_email = "test@example.com" + SiteSetting.google_oauth2_hd_groups_service_account_json = "{}" expect(described_class.has_provider?).to eq(false) SiteSetting.google_oauth2_hd_groups = true diff --git a/spec/requests/omniauth_callbacks_controller_spec.rb b/spec/requests/omniauth_callbacks_controller_spec.rb index 441fc5c8cc0..1bf1e9a9aae 100644 --- a/spec/requests/omniauth_callbacks_controller_spec.rb +++ b/spec/requests/omniauth_callbacks_controller_spec.rb @@ -786,9 +786,7 @@ RSpec.describe Users::OmniauthCallbacksController do end context "when groups are enabled" do - let(:strategy_class) { Auth::OmniAuthStrategies::DiscourseGoogleOauth2 } - let(:groups_url) { "#{strategy_class::GROUPS_DOMAIN}#{strategy_class::GROUPS_PATH}" } - let(:groups_scope) { strategy_class::DEFAULT_SCOPE + strategy_class::GROUPS_SCOPE } + let(:private_key) { OpenSSL::PKey::RSA.generate(2048) } let(:group1) { { id: "12345", name: "group1" } } let(:group2) { { id: "67890", name: "group2" } } let(:uid) { "12345" } @@ -796,22 +794,50 @@ RSpec.describe Users::OmniauthCallbacksController do let(:domain) { "mydomain.com" } def mock_omniauth_for_groups(groups) - raw_groups = groups.map { |group| OmniAuth::AuthHash.new(group) } mock_auth = OmniAuth.config.mock_auth[:google_oauth2] - mock_auth[:extra][:raw_groups] = raw_groups OmniAuth.config.mock_auth[:google_oauth2] = mock_auth Rails.application.env_config["omniauth.auth"] = mock_auth + + SiteSetting.google_oauth2_hd_groups_service_account_admin_email = "admin@example.com" + SiteSetting.google_oauth2_hd_groups_service_account_json = { + "private_key" => private_key.to_s, + "client_email": "discourse-group-sync@example.iam.gserviceaccount.com", + }.to_json + SiteSetting.google_oauth2_hd_groups = true + + stub_request(:post, "https://oauth2.googleapis.com/token").to_return do |request| + jwt = Rack::Utils.parse_query(request.body)["assertion"] + decoded_token = JWT.decode(jwt, private_key.public_key, true, { algorithm: 'RS256' }) + { + status: 200, + body: { "access_token" => token, "type" => "bearer" }.to_json, + headers: { "Content-Type" => "application/json" } + } + end + + stub_request(:get, "https://admin.googleapis.com/admin/directory/v1/groups?userKey=#{mock_auth.uid}"). + with(headers: { "Authorization" => "Bearer #{token}" }). + to_return do + { + status: 200, + body: { groups: groups }.to_json, + headers: { + "Content-Type" => "application/json" + } + } + end end before do SiteSetting.google_oauth2_hd = domain + SiteSetting.google_oauth2_hd_groups_service_account_admin_email = "test@example.com" + SiteSetting.google_oauth2_hd_groups_service_account_json = "{}" SiteSetting.google_oauth2_hd_groups = true end it "updates associated groups" do mock_omniauth_for_groups([group1, group2]) get "/auth/google_oauth2/callback.json", params: { - scope: groups_scope.split(' '), code: 'abcde', hd: domain } @@ -829,7 +855,6 @@ RSpec.describe Users::OmniauthCallbacksController do mock_omniauth_for_groups([group1]) get "/auth/google_oauth2/callback.json", params: { - scope: groups_scope.split(' '), code: 'abcde', hd: domain } @@ -842,7 +867,6 @@ RSpec.describe Users::OmniauthCallbacksController do mock_omniauth_for_groups([]) get "/auth/google_oauth2/callback.json", params: { - scope: groups_scope.split(' '), code: 'abcde', hd: domain } @@ -858,7 +882,6 @@ RSpec.describe Users::OmniauthCallbacksController do mock_omniauth_for_groups([]) get "/auth/google_oauth2/callback.json", params: { - scope: groups_scope.split(' '), code: 'abcde', hd: domain }