Use service account credentials for fetching google hd groups (#18329)

The previous implementation would attempt to fetch groups using the end-user's Google auth token. This only worked for admin accounts, or users with 'delegated' access to the `admin.directory.group.readonly` API.

This commit changes the approach to use a single 'service account' for fetching the groups. This removes the need to add permissions to all regular user accounts. I'll be updating the [meta docs](https://meta.discourse.org/t/226850) with instructions on setting up the service account.

This is technically a breaking change in behavior, but the existing implementation was marked experimental, and is currently unusable in production google workspace environments.
This commit is contained in:
David Taylor
2022-10-13 16:04:42 +01:00
committed by GitHub
parent 45f93ae75d
commit e0a6d12c55
10 changed files with 196 additions and 199 deletions

View File

@@ -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

View File

@@ -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