210 lines
7.4 KiB
Ruby
210 lines
7.4 KiB
Ruby
|
# frozen_string_literal: true
|
||
|
|
||
|
require 'jwt'
|
||
|
require 'omniauth/strategies/oauth2'
|
||
|
require 'uri'
|
||
|
|
||
|
module OmniAuth
|
||
|
module Strategies
|
||
|
# Main class for Google OAuth2 strategy.
|
||
|
class GoogleOauth2 < OmniAuth::Strategies::OAuth2
|
||
|
ALLOWED_ISSUERS = ['accounts.google.com', 'https://accounts.google.com'].freeze
|
||
|
BASE_SCOPE_URL = 'https://www.googleapis.com/auth/'
|
||
|
BASE_SCOPES = %w[profile email openid].freeze
|
||
|
DEFAULT_SCOPE = 'email,profile'
|
||
|
USER_INFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
|
||
|
|
||
|
option :name, 'google_oauth2'
|
||
|
option :skip_friends, true
|
||
|
option :skip_image_info, true
|
||
|
option :skip_jwt, false
|
||
|
option :jwt_leeway, 60
|
||
|
option :authorize_options, %i[access_type hd login_hint prompt request_visible_actions scope state redirect_uri include_granted_scopes openid_realm device_id device_name]
|
||
|
option :authorized_client_ids, []
|
||
|
|
||
|
option :client_options,
|
||
|
site: 'https://oauth2.googleapis.com',
|
||
|
authorize_url: 'https://accounts.google.com/o/oauth2/auth',
|
||
|
token_url: '/token'
|
||
|
|
||
|
def authorize_params
|
||
|
super.tap do |params|
|
||
|
options[:authorize_options].each do |k|
|
||
|
params[k] = request.params[k.to_s] unless [nil, ''].include?(request.params[k.to_s])
|
||
|
end
|
||
|
|
||
|
params[:scope] = get_scope(params)
|
||
|
params[:access_type] = 'offline' if params[:access_type].nil?
|
||
|
params['openid.realm'] = params.delete(:openid_realm) unless params[:openid_realm].nil?
|
||
|
|
||
|
session['omniauth.state'] = params[:state] if params[:state]
|
||
|
end
|
||
|
end
|
||
|
|
||
|
uid { raw_info['sub'] }
|
||
|
|
||
|
info do
|
||
|
prune!(
|
||
|
name: raw_info['name'],
|
||
|
email: raw_info['email'],
|
||
|
email_verified: raw_info['email_verified'],
|
||
|
first_name: raw_info['given_name'],
|
||
|
last_name: raw_info['family_name'],
|
||
|
image: image_url,
|
||
|
urls: {
|
||
|
google: raw_info['profile']
|
||
|
}
|
||
|
)
|
||
|
end
|
||
|
|
||
|
extra do
|
||
|
hash = {}
|
||
|
hash[:id_token] = access_token['id_token']
|
||
|
if !options[:skip_jwt] && !access_token['id_token'].nil?
|
||
|
decoded = ::JWT.decode(access_token['id_token'], nil, false).first
|
||
|
|
||
|
# We have to manually verify the claims because the third parameter to
|
||
|
# JWT.decode is false since no verification key is provided.
|
||
|
::JWT::Verify.verify_claims(decoded,
|
||
|
verify_iss: true,
|
||
|
iss: ALLOWED_ISSUERS,
|
||
|
verify_aud: true,
|
||
|
aud: options.client_id,
|
||
|
verify_sub: false,
|
||
|
verify_expiration: true,
|
||
|
verify_not_before: true,
|
||
|
verify_iat: true,
|
||
|
verify_jti: false,
|
||
|
leeway: options[:jwt_leeway])
|
||
|
|
||
|
hash[:id_info] = decoded
|
||
|
end
|
||
|
hash[:raw_info] = raw_info unless skip_info?
|
||
|
prune! hash
|
||
|
end
|
||
|
|
||
|
def raw_info
|
||
|
@raw_info ||= access_token.get(USER_INFO_URL).parsed
|
||
|
end
|
||
|
|
||
|
def custom_build_access_token
|
||
|
access_token = get_access_token(request)
|
||
|
|
||
|
verify_hd(access_token)
|
||
|
access_token
|
||
|
end
|
||
|
alias build_access_token custom_build_access_token
|
||
|
|
||
|
private
|
||
|
|
||
|
def callback_url
|
||
|
options[:redirect_uri] || (full_host + script_name + callback_path)
|
||
|
end
|
||
|
|
||
|
def get_access_token(request)
|
||
|
if request.xhr? && request.params['code']
|
||
|
verifier = request.params['code']
|
||
|
redirect_uri = request.params['redirect_uri'] || 'postmessage'
|
||
|
client.auth_code.get_token(verifier, get_token_options(redirect_uri), deep_symbolize(options.auth_token_params || {}))
|
||
|
elsif request.params['code'] && request.params['redirect_uri']
|
||
|
verifier = request.params['code']
|
||
|
redirect_uri = request.params['redirect_uri']
|
||
|
client.auth_code.get_token(verifier, get_token_options(redirect_uri), deep_symbolize(options.auth_token_params || {}))
|
||
|
elsif verify_token(request.params['access_token'])
|
||
|
::OAuth2::AccessToken.from_hash(client, request.params.dup)
|
||
|
else
|
||
|
verifier = request.params['code']
|
||
|
client.auth_code.get_token(verifier, get_token_options(callback_url), deep_symbolize(options.auth_token_params))
|
||
|
end
|
||
|
end
|
||
|
|
||
|
def get_scope(params)
|
||
|
raw_scope = params[:scope] || DEFAULT_SCOPE
|
||
|
scope_list = raw_scope.split(' ').map { |item| item.split(',') }.flatten
|
||
|
scope_list.map! { |s| s =~ %r{^https?://} || BASE_SCOPES.include?(s) ? s : "#{BASE_SCOPE_URL}#{s}" }
|
||
|
scope_list.join(' ')
|
||
|
end
|
||
|
|
||
|
def get_token_options(redirect_uri)
|
||
|
{ redirect_uri: redirect_uri }.merge(token_params.to_hash(symbolize_keys: true))
|
||
|
end
|
||
|
|
||
|
def prune!(hash)
|
||
|
hash.delete_if do |_, v|
|
||
|
prune!(v) if v.is_a?(Hash)
|
||
|
v.nil? || (v.respond_to?(:empty?) && v.empty?)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
def image_url
|
||
|
return nil unless raw_info['picture']
|
||
|
|
||
|
u = URI.parse(raw_info['picture'].gsub('https:https', 'https'))
|
||
|
|
||
|
path_index = u.path.to_s.index('/photo.jpg')
|
||
|
|
||
|
if path_index && image_size_opts_passed?
|
||
|
u.path.insert(path_index, image_params)
|
||
|
u.path = u.path.gsub('//', '/')
|
||
|
end
|
||
|
|
||
|
u.query = strip_unnecessary_query_parameters(u.query)
|
||
|
|
||
|
u.to_s
|
||
|
end
|
||
|
|
||
|
def image_size_opts_passed?
|
||
|
options[:image_size] || options[:image_aspect_ratio]
|
||
|
end
|
||
|
|
||
|
def image_params
|
||
|
image_params = []
|
||
|
if options[:image_size].is_a?(Integer)
|
||
|
image_params << "s#{options[:image_size]}"
|
||
|
elsif options[:image_size].is_a?(Hash)
|
||
|
image_params << "w#{options[:image_size][:width]}" if options[:image_size][:width]
|
||
|
image_params << "h#{options[:image_size][:height]}" if options[:image_size][:height]
|
||
|
end
|
||
|
image_params << 'c' if options[:image_aspect_ratio] == 'square'
|
||
|
|
||
|
'/' + image_params.join('-')
|
||
|
end
|
||
|
|
||
|
def strip_unnecessary_query_parameters(query_parameters)
|
||
|
# strip `sz` parameter (defaults to sz=50) which overrides `image_size` options
|
||
|
return nil if query_parameters.nil?
|
||
|
|
||
|
params = CGI.parse(query_parameters)
|
||
|
stripped_params = params.delete_if { |key| key == 'sz' }
|
||
|
|
||
|
# don't return an empty Hash since that would result
|
||
|
# in URLs with a trailing ? character: http://image.url?
|
||
|
return nil if stripped_params.empty?
|
||
|
|
||
|
URI.encode_www_form(stripped_params)
|
||
|
end
|
||
|
|
||
|
def verify_token(access_token)
|
||
|
return false unless access_token
|
||
|
|
||
|
raw_response = client.request(:get, 'https://www.googleapis.com/oauth2/v3/tokeninfo',
|
||
|
params: { access_token: access_token }).parsed
|
||
|
raw_response['aud'] == options.client_id || options.authorized_client_ids.include?(raw_response['aud'])
|
||
|
end
|
||
|
|
||
|
def verify_hd(access_token)
|
||
|
return true unless options.hd
|
||
|
|
||
|
@raw_info ||= access_token.get(USER_INFO_URL).parsed
|
||
|
|
||
|
options.hd = options.hd.call if options.hd.is_a? Proc
|
||
|
allowed_hosted_domains = Array(options.hd)
|
||
|
|
||
|
raise CallbackError.new(:invalid_hd, 'Invalid Hosted Domain') unless allowed_hosted_domains.include?(@raw_info['hd']) || options.hd == '*'
|
||
|
|
||
|
true
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
end
|