Refactoring Gitlab::Geo::OauthSession class

The Gitlab::Geo::OauthSession has a lot of responsibilities,
this changes extracts each responsability into a proper class:

- GitLab::Geo::Oauth::Session
- GitLab::Geo::Oauth::LoginState
- GitLab::Geo::Oauth::LogoutState
- GitLab::Geo::Oauth::LogoutToken
- GitLab::ReturnToLocation
parent 883c8d9b
......@@ -4,39 +4,53 @@ module EE
extend ::Gitlab::Utils::Override
prepended do
before_action :gitlab_geo_login, only: [:new]
before_action :gitlab_geo_logout, only: [:destroy]
end
override :new
def new
return super if signed_in?
if ::Gitlab::Geo.secondary_with_primary?
redirect_to oauth_geo_auth_url(state: geo_login_state.encode)
else
super
end
end
private
def gitlab_geo_login
def gitlab_geo_logout
return unless ::Gitlab::Geo.secondary?
return if signed_in?
oauth = ::Gitlab::Geo::OauthSession.new
# share full url with primary node by oauth state
user_return_to = ::Gitlab::Utils.append_path(root_url, session[:user_return_to])
oauth.return_to = stored_redirect_uri || user_return_to
# The @geo_logout_state instance variable is used within
# ApplicationController#after_sign_out_path_for to redirect
# the user to the logout URL on the primary after sign out
# on the secondary.
@geo_logout_state = geo_logout_state.encode # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
redirect_to oauth_geo_auth_url(state: oauth.generate_oauth_state)
def geo_login_state
::Gitlab::Geo::Oauth::LoginState.new(return_to: geo_return_to_after_login)
end
def gitlab_geo_logout
return unless ::Gitlab::Geo.secondary?
def geo_logout_state
::Gitlab::Geo::Oauth::LogoutState.new(token: session[:access_token], return_to: geo_return_to_after_logout)
end
oauth = ::Gitlab::Geo::OauthSession.new(
access_token: session[:access_token],
return_to: safe_redirect_path_for_url(request.referer)
)
def geo_return_to_after_login
stored_redirect_uri || ::Gitlab::Utils.append_path(root_url, session[:user_return_to].to_s)
end
@geo_logout_state = oauth.generate_logout_state # rubocop:disable Gitlab/ModuleWithInstanceVariables
def geo_return_to_after_logout
safe_redirect_path_for_url(request.referer)
end
override :log_failed_login
def log_failed_login
::AuditEventService.new(request.filtered_parameters['user']['login'], nil, ip_address: request.remote_ip)
.for_failed_login.unauth_security_event
login = request.filtered_parameters.dig('user', 'login')
audit_event_service = ::AuditEventService.new(login, nil, ip_address: request.remote_ip)
audit_event_service.for_failed_login.unauth_security_event
super
end
......
......@@ -3,7 +3,7 @@ class Oauth::GeoAuthController < ActionController::Base
rescue_from OAuth2::Error, with: :auth
def auth
unless oauth.oauth_state_valid?
unless login_state.valid?
redirect_to root_url
return
end
......@@ -12,51 +12,58 @@ class Oauth::GeoAuthController < ActionController::Base
end
def callback
unless oauth.oauth_state_valid?
unless login_state.valid?
redirect_to new_user_session_path
return
end
token = oauth.get_token(params[:code], redirect_uri: oauth_geo_callback_url)
remote_user = oauth.authenticate_with_gitlab(token)
user = UserFinder.new(remote_user['id']).find_by_id
user = user_from_oauth_token(token)
if user && bypass_sign_in(user)
after_sign_in_with_gitlab(token, oauth.get_oauth_state_return_to)
after_sign_in_with_gitlab(token)
else
invalid_credentials
end
end
def logout
logout = Oauth2::LogoutTokenValidationService.new(current_user, params)
result = logout.execute
token = Gitlab::Geo::Oauth::LogoutToken.new(current_user, params[:state])
if result[:status] == :success
if token.valid?
sign_out current_user
after_sign_out_with_gitlab(result[:return_to])
after_sign_out_with_gitlab(token)
else
access_token_error(result[:message])
invalid_access_token(token)
end
end
private
def oauth
@oauth ||= Gitlab::Geo::OauthSession.new(state: params[:state])
@oauth ||= Gitlab::Geo::Oauth::Session.new
end
def after_sign_in_with_gitlab(token, return_to)
def user_from_oauth_token(token)
remote_user = oauth.authenticate(token)
UserFinder.new(remote_user['id']).find_by_id if remote_user
end
def login_state
Gitlab::Geo::Oauth::LoginState.from_state(params[:state])
end
def after_sign_in_with_gitlab(token)
session[:access_token] = token
# Prevent alert from popping up on the first page shown after authentication.
flash[:alert] = nil
redirect_to(return_to || root_path)
redirect_to(login_state.return_to || root_path)
end
def after_sign_out_with_gitlab(return_to)
session[:user_return_to] = return_to
def after_sign_out_with_gitlab(token)
session[:user_return_to] = token.return_to
redirect_to(root_path)
end
......@@ -70,8 +77,9 @@ class Oauth::GeoAuthController < ActionController::Base
render :error, layout: 'errors'
end
def access_token_error(status)
@error = "There is a problem with the OAuth access_token: #{status}"
def invalid_access_token(token)
message = token.errors.full_messages.join(', ')
@error = "There is a problem with the OAuth access_token: #{message}"
render :error, layout: 'errors'
end
end
module Oauth2
class LogoutTokenValidationService < ::BaseService
include Gitlab::Utils::StrongMemoize
attr_reader :state
def initialize(user, params = {})
@current_user = user
@state = params[:state]
end
def execute
return error('Access token could not be found') unless access_token.present?
status = AccessTokenValidationService.new(access_token).validate
return error(status) unless status == AccessTokenValidationService::VALID
user = User.find(access_token.resource_owner_id)
if user && user == current_user
success(return_to: user_return_to)
else
error('User could not be found')
end
end
private
def access_token
strong_memoize(:access_token) do
logout_token = oauth_session.extract_logout_token
if logout_token&.is_utf8?
Doorkeeper::AccessToken.by_token(logout_token)
else
nil
end
end
end
def oauth_session
@oauth_session ||= Gitlab::Geo::OauthSession.new(state: state)
end
def user_return_to
full_path = oauth_session.get_oauth_state_return_to_full_path
Gitlab::Utils.append_path(geo_node_url, full_path)
end
def geo_node_url
GeoNode.find_by_oauth_application_id(access_token.application_id)&.url
end
end
end
# frozen_string_literal: true
module Gitlab
module Geo
module Oauth
class LoginState
attr_reader :return_to
def self.from_state(state)
salt, hmac, return_to = state.to_s.split(':', 3)
self.new(salt: salt, hmac: hmac, return_to: return_to)
end
def initialize(return_to:, salt: nil, hmac: nil)
@return_to = return_to
@salt = salt
@hmac = hmac
end
def valid?
return false unless salt.present? && hmac.present?
hmac == generate_hmac
end
def encode
"#{salt}:#{generate_hmac}:#{return_to}"
end
private
attr_reader :hmac
def generate_hmac
digest = OpenSSL::Digest::SHA256.new
key = Gitlab::Application.secrets.secret_key_base + salt
OpenSSL::HMAC.hexdigest(digest, key, return_to.to_s)
end
def salt
@salt ||= SecureRandom.hex(8)
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Geo
module Oauth
class LogoutState
def self.from_state(state)
salt, encrypted, return_to = state.to_s.split(':', 3)
self.new(salt: salt, token: encrypted, return_to: return_to)
end
def initialize(token:, salt: nil, return_to: nil)
@token = token
@salt = salt
@return_to_location = Gitlab::ReturnToLocation.new(return_to)
end
def decode
return unless salt && token
decoded = Base64.urlsafe_decode64(token)
decrypt = cipher(salt, :decrypt)
decrypt.update(decoded) + decrypt.final
rescue OpenSSL::OpenSSLError
nil
end
def encode
return unless token
iv = salt || SecureRandom.hex(8)
encrypt = cipher(iv, :encrypt)
encrypted = encrypt.update(token) + encrypt.final
encoded = Base64.urlsafe_encode64(encrypted)
"#{iv}:#{encoded}:#{return_to}"
rescue OpenSSL::OpenSSLError
nil
end
def return_to
return_to_location.full_path
end
private
attr_reader :token, :salt, :return_to_location
def cipher(salt, operation)
cipher = OpenSSL::Cipher::AES.new(128, :CBC)
cipher.__send__(operation) # rubocop:disable GitlabSecurity/PublicSend
cipher.iv = salt
cipher.key = Settings.attr_encrypted_db_key_base.first(16)
cipher
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Geo
module Oauth
class LogoutToken
include ActiveModel::Validations
include Gitlab::Utils::StrongMemoize
validates :current_user, :token, presence: { message: 'could not be found' }
validate :owner, if: :token
validate :status, if: :token
def initialize(current_user, raw_state)
@current_user = current_user
@raw_state = raw_state
end
def return_to
return unless valid?
return unless node
Gitlab::Utils.append_path(node.url, state.return_to)
end
private
attr_reader :current_user, :raw_state
def state
strong_memoize(:state) do
Gitlab::Geo::Oauth::LogoutState.from_state(raw_state)
end
end
def token
strong_memoize(:token) do
decoded_token = state.decode
if decoded_token&.is_utf8?
Doorkeeper::AccessToken.by_token(decoded_token)
else
nil
end
end
end
def node
strong_memoize(:node) do
GeoNode.find_by_oauth_application_id(token.application_id)
end
end
def status
result = AccessTokenValidationService.new(token).validate
unless result == AccessTokenValidationService::VALID
errors.add(:base, "Token has #{result}")
end
end
def owner
resource_owner = User.find(token.resource_owner_id)
unless resource_owner && resource_owner == current_user
errors.add(:base, 'User could not be found')
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Geo
module Oauth
class Session
include Gitlab::Routing
include Gitlab::Utils::StrongMemoize
include GrapePathHelpers::NamedRouteMatcher
def authorize_url(params = {})
oauth_client.auth_code.authorize_url(params)
end
def authenticate(access_token)
api = OAuth2::AccessToken.from_hash(oauth_client, access_token: access_token)
api.get(api_v4_user_path).parsed
end
def get_token(code, params = {}, opts = {})
oauth_client.auth_code.get_token(code, params, opts).token
end
private
def oauth_application
strong_memoize(:oauth_application) do
Gitlab::Geo.oauth_authentication
end
end
def oauth_client
strong_memoize(:oauth_client) do
::OAuth2::Client.new(
oauth_application&.uid,
oauth_application&.secret,
site: Gitlab::Geo.primary_node.url,
authorize_url: oauth_authorization_path,
token_url: oauth_token_path
)
end
end
end
end
end
end
module Gitlab
module Geo
class OauthSession
include ActiveModel::Model
attr_accessor :access_token
attr_accessor :state
attr_accessor :return_to
def oauth_state_valid?
salt, hmac, return_to = state.to_s.split(':', 3)
LoginState.new(salt, return_to).valid?(hmac)
end
def generate_oauth_state
self.state = LoginState.new(oauth_salt, return_to).encode
end
def generate_logout_state
self.state = LogoutState.new(oauth_salt, access_token, return_to).encode
end
def extract_logout_token
salt, encrypted, return_to = state.to_s.split(':', 3)
LogoutState.new(salt, encrypted, return_to).decode
end
def get_oauth_state_return_to
state.split(':', 3)[2] if state
end
def get_oauth_state_return_to_full_path
ReturnToLocation.new(get_oauth_state_return_to).full_path
end
def authorize_url(params = {})
oauth_client.auth_code.authorize_url(params)
end
def get_token(code, params = {}, opts = {})
oauth_client.auth_code.get_token(code, params, opts).token
end
def authenticate_with_gitlab(access_token)
return false unless access_token
api = OAuth2::AccessToken.from_hash(oauth_client, access_token: access_token)
api.get('/api/v4/user').parsed
end
private
class LoginState
def initialize(salt, return_to)
@salt = salt
@return_to = return_to
end
def valid?(hmac)
return false unless salt && return_to
hmac == generate_hmac
end
def encode
return unless salt && return_to
"#{salt}:#{generate_hmac}:#{return_to}"
end
private
attr_reader :salt, :return_to
def generate_hmac
digest = OpenSSL::Digest.new('sha256')
key = Gitlab::Application.secrets.secret_key_base + salt
OpenSSL::HMAC.hexdigest(digest, key, return_to)
end
end
class LogoutState
def initialize(salt, token, return_to)
@salt = salt
@token = token
@return_to = return_to
end
def decode
return unless salt && token
decrypt = cipher(salt, :decrypt)
decrypt.update(Base64.urlsafe_decode64(token)) + decrypt.final
rescue OpenSSL::OpenSSLError
nil
end
def encode
return unless token
encrypt = cipher(salt, :encrypt)
encrypted = encrypt.update(token) + encrypt.final
encoded = Base64.urlsafe_encode64(encrypted)
"#{salt}:#{encoded}:#{full_path}"
rescue OpenSSL::OpenSSLError
nil
end
private
attr_reader :salt, :token, :return_to
def cipher(salt, operation)
cipher = OpenSSL::Cipher::AES.new(128, :CBC)
cipher.__send__(operation) # rubocop:disable GitlabSecurity/PublicSend
cipher.iv = salt
cipher.key = Settings.attr_encrypted_db_key_base[0..15]
cipher
end
def full_path
ReturnToLocation.new(return_to).full_path
end
end
class ReturnToLocation
def initialize(location)
@location = location
end
def full_path
uri = parse_uri(location)
if uri
path = remove_domain_from_uri(uri)
path = add_fragment_back_to_path(uri, path)
path
end
end
private
attr_reader :location
def parse_uri(location)
location && URI.parse(location.sub(%r{\A\/\/+}, '/'))
rescue URI::InvalidURIError
nil
end
def remove_domain_from_uri(uri)
[uri.path.sub(%r{\A\/+}, '/'), uri.query].compact.join('?')
end
def add_fragment_back_to_path(uri, path)
[path, uri.fragment].compact.join('#')
end
end
def oauth_salt
@oauth_salt ||= SecureRandom.hex(8)
end
def oauth_client
@oauth_client ||= begin
::OAuth2::Client.new(
oauth_app.uid,
oauth_app.secret,
{
site: primary_node_url,
authorize_url: 'oauth/authorize',
token_url: 'oauth/token'
}
)
end
end
def oauth_app
Gitlab::Geo.oauth_authentication
end
def primary_node_url
Gitlab::Geo.primary_node.url
end
end
end
end
# frozen_string_literal: true
module Gitlab
class ReturnToLocation
def initialize(location)
@location = location
end
def full_path
uri = parse_uri
if uri
path = remove_domain_from_uri(uri)
path = add_fragment_back_to_path(uri, path)
path
end
end
private
attr_reader :location
def parse_uri
location && URI.parse(location.sub(%r{\A\/\/+}, '/'))
rescue URI::InvalidURIError
nil
end
def remove_domain_from_uri(uri)
[uri.path.sub(%r{\A\/+}, '/'), uri.query].compact.join('?')
end
def add_fragment_back_to_path(uri, path)
[path, uri.fragment].compact.join('#')
end
end
end
require 'spec_helper'
describe Oauth::GeoAuthController do
include EE::GeoHelpers
# The Geo OAuth workflow depends on the OAuth application and the URL
# defined on the Geo primary node, so we use let! instead of let here
# to define a memoized helper method that is called in a `before` hook
# doing the proper set up for us.
let!(:primary_node) { create(:geo_node, :primary) }
let(:secondary_node) { create(:geo_node) }
let(:user) { create(:user) }
let(:node) { create(:geo_node) }
let(:oauth_app) { node.oauth_application }
let(:access_token) { create(:doorkeeper_access_token, resource_owner_id: user.id, application: oauth_app) }
let(:oauth_session) { Gitlab::Geo::OauthSession.new(access_token: access_token.token, return_to: projects_url) }
let(:auth_state) { oauth_session.generate_oauth_state }
let(:primary_node_url) { 'http://localhost:3001/' }
before do
allow_any_instance_of(Gitlab::Geo::OauthSession).to receive(:oauth_app) { oauth_app }
allow_any_instance_of(Gitlab::Geo::OauthSession).to receive(:primary_node_url) { primary_node_url }
end
let(:oauth_application) { secondary_node.oauth_application }
let(:access_token) { create(:doorkeeper_access_token, application: oauth_application, resource_owner_id: user.id) }
let(:login_state) { Gitlab::Geo::Oauth::LoginState.new(return_to: secondary_node.url).encode }
describe 'GET auth' do
let(:primary_node_oauth_endpoint) { Gitlab::Geo::OauthSession.new.authorize_url(redirect_uri: oauth_geo_callback_url, state: auth_state) }
before do
stub_current_geo_node(secondary_node)
end
it 'redirects to root_url when state is invalid' do
allow_any_instance_of(Gitlab::Geo::OauthSession).to receive(:oauth_state_valid?) { false }
get :auth, state: auth_state
allow_any_instance_of(Gitlab::Geo::Oauth::LoginState).to receive(:valid?).and_return(false)
get :auth, state: login_state
expect(response).to redirect_to(root_url)
end
it "redirects to primary node's oauth endpoint" do
get :auth, state: auth_state
oauth_endpoint = Gitlab::Geo::Oauth::Session.new.authorize_url(redirect_uri: oauth_geo_callback_url, state: login_state)
get :auth, state: login_state
expect(response).to redirect_to(primary_node_oauth_endpoint)
expect(response).to redirect_to(oauth_endpoint)
end
end
describe 'GET callback' do
let(:oauth_session) { Gitlab::Geo::OauthSession.new(access_token: access_token.token, return_to: projects_url) }
let(:callback_state) { oauth_session.generate_oauth_state }
let(:primary_node_oauth_endpoint) { Gitlab::Geo::OauthSession.new.authorize_url(redirect_uri: oauth_geo_callback_url, state: callback_state) }
before do
stub_current_geo_node(secondary_node)
end
context 'redirection' do
before do
allow_any_instance_of(Gitlab::Geo::OauthSession).to receive(:get_token) { 'token' }
allow_any_instance_of(Gitlab::Geo::OauthSession).to receive(:authenticate_with_gitlab) { user.attributes }
allow_any_instance_of(Gitlab::Geo::Oauth::Session).to receive(:get_token).and_return('token')
allow_any_instance_of(Gitlab::Geo::Oauth::Session).to receive(:authenticate).and_return(user.attributes)
end
it 'redirects to login screen if state is invalid' do
allow_any_instance_of(Gitlab::Geo::OauthSession).to receive(:oauth_state_valid?) { false }
get :callback, state: callback_state
allow_any_instance_of(Gitlab::Geo::Oauth::LoginState).to receive(:valid?).and_return(false)
get :callback, state: login_state
expect(response).to redirect_to(new_user_session_path)
end
it 'redirects to redirect_url if state is valid' do
get :callback, state: callback_state
get :callback, state: login_state
expect(response).to redirect_to(projects_url)
expect(response).to redirect_to(secondary_node.url)
end
it 'does not display a flash message if state is valid' do
get :callback, state: callback_state
get :callback, state: login_state
expect(controller).to set_flash[:alert].to(nil)
end
......@@ -67,27 +73,45 @@ describe Oauth::GeoAuthController do
let(:oauth_error) { OAuth2::Error.new(OAuth2::Response.new(fake_response)) }
before do
expect_any_instance_of(Gitlab::Geo::OauthSession).to receive(:get_token) { access_token.token }
expect_any_instance_of(Gitlab::Geo::OauthSession).to receive(:authenticate_with_gitlab).and_raise(oauth_error)
allow_any_instance_of(Gitlab::Geo::Oauth::Session).to receive(:get_token).and_return(access_token.token)
allow_any_instance_of(Gitlab::Geo::Oauth::Session).to receive(:authenticate).and_raise(oauth_error)
end
it 'handles invalid credentials error' do
get :callback, state: callback_state
oauth_endpoint = Gitlab::Geo::Oauth::Session.new.authorize_url(redirect_uri: oauth_geo_callback_url, state: login_state)
get :callback, state: login_state
expect(response).to redirect_to(oauth_endpoint)
end
end
context 'non-existent remote user' do
render_views
before do
allow_any_instance_of(Gitlab::Geo::Oauth::Session).to receive(:get_token).and_return('token')
allow_any_instance_of(Gitlab::Geo::Oauth::Session).to receive(:authenticate).and_return(nil)
end
it 'handles non-existent remote user error' do
get :callback, state: login_state
expect(response).to redirect_to(primary_node_oauth_endpoint)
expect(response.code).to eq '200'
expect(response.body).to include('Your account may have been deleted')
end
end
context 'inexistent local user' do
context 'non-existent local user' do
render_views
before do
expect_any_instance_of(Gitlab::Geo::OauthSession).to receive(:get_token) { 'token' }
expect_any_instance_of(Gitlab::Geo::OauthSession).to receive(:authenticate_with_gitlab) { User.new(id: 999999) }
allow_any_instance_of(Gitlab::Geo::Oauth::Session).to receive(:get_token).and_return('token')
allow_any_instance_of(Gitlab::Geo::Oauth::Session).to receive(:authenticate).and_return(id: 999999)
end
it 'handles inexistent local user error' do
get :callback, state: callback_state
it 'handles non-existent local user error' do
get :callback, state: login_state
expect(response.code).to eq '200'
expect(response.body).to include('Your account may have been deleted')
......@@ -96,8 +120,7 @@ describe Oauth::GeoAuthController do
end
describe 'GET logout' do
let(:oauth_session) { Gitlab::Geo::OauthSession.new(access_token: access_token.token) }
let(:logout_state) { oauth_session.generate_logout_state }
let(:logout_state) { Gitlab::Geo::Oauth::LogoutState.new(token: access_token.token).encode }
render_views
......@@ -109,17 +132,20 @@ describe Oauth::GeoAuthController do
it 'logs out and redirects to the root_url' do
get :logout, state: logout_state
expect(assigns(:current_user)).to be_nil
expect(response).to redirect_to root_url
end
end
context 'when access_token is invalid' do
it 'handles access token problems' do
allow_any_instance_of(Oauth2::LogoutTokenValidationService).to receive(:execute) { { status: :error, message: :expired } }
it 'shows access token errors' do
allow(Doorkeeper::AccessToken)
.to receive(:by_token)
.and_return(double(resource_owner_id: user.id, expired?: true))
get :logout, state: logout_state
expect(response.body).to include("There is a problem with the OAuth access_token: expired")
expect(response.body).to include("There is a problem with the OAuth access_token: Token has expired")
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Geo::Oauth::LoginState do
let(:salt) { '100d8cbd1750a2bb' }
let(:hmac) { '62fdcface89baab582f33de6672f10499974c28b5cc269795c4830b8b3ab06be' }
let(:oauth_return_to) { 'http://fake-secondary.com:3000/project/test' }
before do
allow(Gitlab::Application.secrets).to receive(:secret_key_base)
.and_return('712f2a504647cb8aa7b06f2273c1db026dd9d2566acf228cd44f25f7d372c165af72209f34cb9df6623780425ce717884cf0c7f85c1deb108bd45a8cd2c93427')
end
describe '.from_state' do
it 'returns a invalid instance when state is nil' do
expect(described_class.from_state(nil)).not_to be_valid
end
it 'returns a invalid instance when state is empty' do
expect(described_class.from_state('')).not_to be_valid
end
it 'returns a valid instance when state is valid' do
expect(described_class.from_state("#{salt}:#{hmac}:#{oauth_return_to}")).to be_valid
end
end
describe '#valid?' do
it 'returns false when return_to is nil' do
subject = described_class.new(return_to: nil)
expect(subject.valid?).to eq false
end
it 'returns false when return_to is empty' do
subject = described_class.new(return_to: '')
expect(subject.valid?).to eq false
end
it 'returns false when hmac is nil' do
subject = described_class.new(return_to: oauth_return_to, salt: salt, hmac: nil)
expect(subject.valid?).to eq false
end
it 'returns false when hmac is empty' do
subject = described_class.new(return_to: oauth_return_to, salt: salt, hmac: '')
expect(subject.valid?).to eq false
end
it 'returns false when salt not match' do
subject = described_class.new(return_to: oauth_return_to, salt: 'salt', hmac: hmac)
expect(subject.valid?).to eq(false)
end
it 'returns false when hmac does not match' do
subject = described_class.new(return_to: oauth_return_to, salt: salt, hmac: 'hmac')
expect(subject.valid?).to eq(false)
end
it 'returns true when hmac matches' do
subject = described_class.new(return_to: oauth_return_to, salt: salt, hmac: hmac)
expect(subject.valid?).to eq(true)
end
end
describe '#encode' do
it 'does not raise an error when return_to is nil' do
subject = described_class.new(return_to: nil)
expect { subject.encode }.not_to raise_error
end
it 'returns a string with salt, hmac, and return_to colon separated' do
subject = described_class.new(return_to: oauth_return_to)
salt, hmac, return_to = subject.encode.split(':', 3)
expect(salt).not_to be_blank
expect(hmac).not_to be_blank
expect(return_to).to eq oauth_return_to
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Geo::Oauth::LogoutState do
let(:salt) { '100d8cbd1750a2bb' }
let(:return_to) { 'http://fake-secondary.com:3000/project/test' }
let(:access_token) { '48622af3df5b5b3e09b9754f2a3e5f3f10a94b4147d155b1029d827c112524d1' }
let(:encrypted_token) { 'fDyMq6IrHGhToG5NHiXnQ4O8AsHmSDqDTqbLP64MK0L9j0rkPEnrNDBSoWU-QS2l7sIt_Q4UMItxFhFH6xMh68uspgydVysRG9fmr_PXIU4=' }
before do
allow(Settings).to receive(:attr_encrypted_db_key_base)
.and_return('4587f5984bf8f807ee320ed7b783e0c56b644a18fdcf5bc79bb2b5b38edbbb1a7037e8d79cbc880cc593880cd3ce87906ebb38466428dfd0dc70a626bb28b7ba')
end
describe '#encode' do
it 'returns nil when token is nil' do
subject = described_class.new(token: nil, return_to: return_to)
expect(subject.encode).to be_nil
end
it 'returns nil when encryption fails' do
allow_any_instance_of(OpenSSL::Cipher::AES)
.to receive(:final) { raise OpenSSL::OpenSSLError }
subject = described_class.new(token: access_token, return_to: return_to)
expect(subject.encode).to be_nil
end
it 'returns a string with salt, encrypted access token, and return_to full path colon separated' do
subject = described_class.new(salt: salt, token: access_token, return_to: return_to)
expect(subject.encode).to eq("#{salt}:#{encrypted_token}:/project/test")
end
it 'includes a empty value for return_to into state when return_to is nil' do
subject = described_class.new(token: access_token, return_to: nil)
state = subject.encode
expect(state.split(':', 3)[2]).to eq ''
end
end
describe '#decode' do
it 'returns nil when salt is nil' do
subject = described_class.new(salt: nil, token: encrypted_token, return_to: return_to)
expect(subject.decode).to be_nil
end
it 'returns nil when encrypted token is nil' do
subject = described_class.new(salt: salt, token: nil, return_to: return_to)
expect(subject.decode).to be_nil
end
it 'returns nil when decryption fails' do
allow_any_instance_of(OpenSSL::Cipher::AES)
.to receive(:final) { raise OpenSSL::OpenSSLError }
subject = described_class.new(salt: salt, token: encrypted_token, return_to: return_to)
expect(subject.decode).to be_nil
end
it 'returns access_token when token is recoverable' do
subject = described_class.new(salt: salt, token: encrypted_token, return_to: return_to)
expect(subject.decode).to eq(access_token)
end
end
describe '#return_to' do
it 'returns nil when return_to is nil' do
subject = described_class.new(salt: salt, token: access_token, return_to: nil)
expect(subject.return_to).to be_nil
end
it 'returns an emtpy string when return_to is empty' do
subject = described_class.new(salt: salt, token: access_token, return_to: '')
expect(subject.return_to).to eq('')
end
it 'returns the full path of the return_to URL' do
subject = described_class.new(salt: salt, token: access_token, return_to: return_to)
expect(subject.return_to).to eq('/project/test')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Geo::Oauth::LogoutToken do
let(:user) { create(:user) }
let(:node) { create(:geo_node) }
let(:access_token) { create(:doorkeeper_access_token, resource_owner_id: user.id, application_id: node.oauth_application_id) }
let(:return_to) { '/project/test' }
let(:state) { Gitlab::Geo::Oauth::LogoutState.new(token: access_token.token, return_to: return_to).encode }
describe '#valid?' do
it 'returns false when current user is nil' do
token = described_class.new(nil, state)
expect(token).not_to be_valid
expect(token.errors.full_messages).to include('Current user could not be found')
end
it 'returns false when state is nil' do
token = described_class.new(user, nil)
expect(token).not_to be_valid
expect(token.errors.full_messages).to include('Token could not be found')
end
it 'returns false when state is empty' do
token = described_class.new(user, '')
expect(token).not_to be_valid
expect(token.errors.full_messages).to include('Token could not be found')
end
it 'returns false when token has an incorrect encoding' do
allow_any_instance_of(Gitlab::Geo::Oauth::LogoutState)
.to receive(:decode)
.and_return("\xD800\xD801\xD802")
token = described_class.new(user, state)
expect(token).not_to be_valid
expect(token.errors.full_messages).to include('Token could not be found')
end
it 'returns false when token could not be found' do
allow(Doorkeeper::AccessToken)
.to receive(:by_token)
.and_return(nil)
token = described_class.new(user, state)
expect(token).not_to be_valid
expect(token.errors.full_messages).to include('Token could not be found')
end
it 'returns false when token has an invalid status' do
allow(Doorkeeper::AccessToken)
.to receive(:by_token)
.and_return(double(resource_owner_id: user.id, expired?: true))
token = described_class.new(user, state)
expect(token).not_to be_valid
expect(token.errors.full_messages).to include('Token has expired')
end
it 'returns false when token does not belong to the user' do
allow(Doorkeeper::AccessToken)
.to receive(:by_token)
.and_return(double(resource_owner_id: user.id, expired?: true))
token = described_class.new(create(:user), state)
expect(token).not_to be_valid
expect(token.errors.full_messages).to include('User could not be found')
end
it 'returns true when token is valid' do
token = described_class.new(user, state)
expect(token).to be_valid
end
end
describe '#return_to' do
it 'returns nil when token is invalid' do
token = described_class.new(user, nil)
expect(token.return_to).to be_nil
end
it 'returns nil when there is no Geo node associated with the OAuth application' do
allow(GeoNode)
.to receive(:find_by_oauth_application_id)
.with(node.oauth_application_id)
.and_return(nil)
token = described_class.new(user, state)
expect(token.return_to).to be_nil
end
context 'when state return_to param is nil' do
it 'returns the Geo node URL associated with the OAuth application' do
state = Gitlab::Geo::Oauth::LogoutState.new(token: access_token.token, return_to: nil).encode
token = described_class.new(user, state)
expect(token.return_to).to eq(node.url)
end
end
context 'when state return_to param is empty' do
it 'returns the Geo node URL associated with the OAuth application' do
state = Gitlab::Geo::Oauth::LogoutState.new(token: access_token.token, return_to: '').encode
token = described_class.new(user, state)
expect(token.return_to).to eq(node.url)
end
end
context 'when state return_to param is set' do
let(:return_to_url) { "#{node.url.chomp('/')}/project/test" }
it 'returns the full path to the Geo node URL associated with the OAuth application' do
token = described_class.new(user, state)
expect(token.return_to).to eq(return_to_url)
end
it 'replaces the host with the Geo node associated with the OAuth application' do
fake_return_to = 'http://fake-secondary/project/test'
state = Gitlab::Geo::Oauth::LogoutState.new(token: access_token.token, return_to: fake_return_to).encode
token = described_class.new(user, state)
expect(token.return_to).to eq(return_to_url)
end
it 'handles leading and trailing slashes correctly' do
return_to = '//project/test'
state = Gitlab::Geo::Oauth::LogoutState.new(token: access_token.token, return_to: return_to).encode
token = described_class.new(user, state)
expect(token.return_to).to eq(return_to_url)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Geo::Oauth::Session do
include EE::GeoHelpers
let!(:primary_node) { create(:geo_node, :primary) }
let(:secondary_node) { create(:geo_node) }
let(:oauth_application) { secondary_node.oauth_application }
let(:access_token) { create(:doorkeeper_access_token, application: oauth_application) }
before do
stub_current_geo_node(secondary_node)
end
describe '#authorized_url' do
it 'returns a valid url to the primary node' do
expect(subject.authorize_url).to start_with(primary_node.url)
end
end
describe '#authenticate' do
let(:api_url) { "#{primary_node.url.chomp('/')}/api/v4/user" }
let(:user_json) { ActiveSupport::JSON.encode({ id: 555, email: 'user@example.com' }.as_json) }
context 'on success' do
before do
stub_request(:get, api_url).to_return(
body: user_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns hashed user data' do
parsed_json = JSON.parse(user_json)
expect(subject.authenticate(access_token.token)).to eq(parsed_json)
end
end
context 'on invalid token' do
before do
stub_request(:get, api_url).to_return(status: [401, 'Unauthorized'])
end
it 'raises exception' do
expect { subject.authenticate(access_token.token) }.to raise_error(OAuth2::Error)
end
end
end
end
require 'spec_helper'
describe Gitlab::Geo::OauthSession do
subject { described_class.new }
let(:oauth_app) { FactoryBot.create(:doorkeeper_application) }
let(:oauth_return_to) { 'http://secondary/oauth/geo/callback' }
let(:dummy_state) { 'salt:hmac:return_to' }
let(:valid_state) { described_class.new(return_to: oauth_return_to).generate_oauth_state }
let(:access_token) { FactoryBot.create(:doorkeeper_access_token).token }
before do
allow(subject).to receive(:oauth_app) { oauth_app }
allow(subject).to receive(:primary_node_url) { 'http://primary/' }
end
describe '#oauth_state_valid?' do
it 'returns false when state is not present' do
expect(subject.oauth_state_valid?).to be_falsey
end
it 'returns false when return_to cannot be retrieved' do
subject.state = 'invalidstate'
expect(subject.oauth_state_valid?).to be_falsey
end
it 'returns false when hmac does not match' do
subject.state = dummy_state
expect(subject.oauth_state_valid?).to be_falsey
end
it 'returns true when hmac matches generated one' do
subject.state = valid_state
expect(subject.oauth_state_valid?).to be_truthy
end
end
describe '#generate_oauth_state' do
it 'returns nil when return_to is not present' do
expect(subject.generate_oauth_state).to be_nil
end
context 'when return_to is present' do
it 'returns a string' do
expect(valid_state).to be_a String
expect(valid_state).not_to be_empty
end
it 'includes return_to value' do
expect(valid_state).to include(oauth_return_to)
end
end
end
describe '#get_oauth_state_return_to' do
it 'returns return_to value' do
subject = described_class.new(state: valid_state)
expect(subject.get_oauth_state_return_to).to eq(oauth_return_to)
end
end
describe '#get_oauth_state_return_to_full_path' do
it 'removes the domain from return_to value' do
subject = described_class.new(state: valid_state)
expect(subject.get_oauth_state_return_to_full_path).to eq('/oauth/geo/callback')
end
end
describe '#generate_logout_state' do
it 'returns nil when access_token is not defined' do
expect(described_class.new.generate_logout_state).to be_nil
end
it 'returns false when encryptation fails' do
allow_any_instance_of(OpenSSL::Cipher::AES)
.to receive(:final) { raise OpenSSL::OpenSSLError }
expect(subject.generate_logout_state).to be_falsey
end
it 'returns a string with salt, encrypted access token, and return_to colon separated' do
subject = described_class.new(access_token: access_token, return_to: oauth_return_to)
state = subject.generate_logout_state
expect(state).to be_a String
expect(state).not_to be_blank
salt, encrypted, return_to = state.split(':', 3)
expect(salt).not_to be_blank
expect(encrypted).not_to be_blank
expect(return_to).not_to be_blank
end
it 'include a empty value for return_to into state when return_to param is not defined' do
subject = described_class.new(access_token: access_token)
state = subject.generate_logout_state
_, _, return_to = state.split(':', 3)
expect(return_to).to eq ''
end
it 'does not include the host from return_to param into into the state' do
subject = described_class.new(access_token: access_token, return_to: oauth_return_to)
state = subject.generate_logout_state
_, _, return_to = state.split(':', 3)
expect(return_to).to eq '/oauth/geo/callback'
end
end
describe '#extract_logout_token' do
subject { described_class.new(access_token: access_token) }
it 'returns nil when state is not defined' do
expect(subject.extract_logout_token).to be_nil
end
it 'returns nil when state is empty' do
subject.state = ''
expect(subject.extract_logout_token).to be_nil
end
it 'returns false when decryptation fails' do
allow_any_instance_of(OpenSSL::Cipher::AES)
.to receive(:final) { raise OpenSSL::OpenSSLError }
expect(subject.extract_logout_token).to be_falsey
end
it 'encrypted access token is recoverable' do
subject.generate_logout_state
access_token = subject.extract_logout_token
expect(access_token).to eq access_token
end
end
describe '#authorized_url' do
subject { described_class.new(return_to: oauth_return_to) }
it 'returns a valid url' do
expect(subject.authorize_url).to be_a String
expect(subject.authorize_url).to include('http://primary/')
end
end
describe '#authenticate_with_gitlab' do
let(:api_url) { 'http://primary/api/v4/user' }
let(:user_json) { ActiveSupport::JSON.encode({ id: 555, email: 'user@example.com' }.as_json) }
context 'on success' do
before do
stub_request(:get, api_url).to_return(
body: user_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns hashed user data' do
parsed_json = JSON.parse(user_json)
expect(subject.authenticate_with_gitlab(access_token)).to eq(parsed_json)
end
end
context 'on invalid token' do
before do
stub_request(:get, api_url).to_return(status: [401, "Unauthorized"])
end
it 'raises exception' do
expect { subject.authenticate_with_gitlab(access_token) }.to raise_error(OAuth2::Error)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::ReturnToLocation do
describe '#full_path' do
it 'returns nil when location nil' do
subject = described_class.new(nil)
expect(subject.full_path).to be_nil
end
it 'returns an empty string when location is empty' do
subject = described_class.new('')
expect(subject.full_path).to eq ''
end
it 'removes the domain from location' do
subject = described_class.new('http://example.com/foo/bar')
expect(subject.full_path).to eq '/foo/bar'
end
it 'keeps the query string from location' do
subject = described_class.new('http://example.com/foo/bar?a=1&b=2')
expect(subject.full_path).to eq '/foo/bar?a=1&b=2'
end
it 'keeps the fragments from location' do
subject = described_class.new('http://example.com/foo/bar#section')
expect(subject.full_path).to eq '/foo/bar#section'
end
end
end
require 'spec_helper'
describe Oauth2::LogoutTokenValidationService do
let(:user) { create(:user) }
let(:node) { create(:geo_node) }
let(:access_token) { create(:doorkeeper_access_token, resource_owner_id: user.id, application_id: node.oauth_application_id) }
let(:oauth_return_to) { '/project/test' }
let(:oauth_session) { Gitlab::Geo::OauthSession.new(access_token: access_token.token, return_to: oauth_return_to) }
let(:logout_state) { oauth_session.generate_logout_state }
context '#execute' do
it 'return error when params are empty' do
result = described_class.new(user, {}).execute
expect(result[:status]).to eq(:error)
end
it 'returns error when state param is nil' do
result = described_class.new(user, state: nil).execute
expect(result[:status]).to eq(:error)
end
it 'returns error when state param is empty' do
result = described_class.new(user, state: '').execute
expect(result[:status]).to eq(:error)
end
it 'returns error when token has incorrect encoding' do
allow_any_instance_of(Gitlab::Geo::OauthSession)
.to receive(:extract_logout_token)
.and_return("\xD800\xD801\xD802")
result = described_class.new(user, state: logout_state).execute
expect(result[:status]).to eq(:error)
end
it 'returns error when current user is nil' do
result = described_class.new(nil, state: logout_state).execute
expect(result).to eq(status: :error, message: 'User could not be found')
end
it 'returns error when token owner could not be found' do
allow(User).to receive(:find).with(user.id).and_return(nil)
result = described_class.new(user, state: logout_state).execute
expect(result).to eq(status: :error, message: 'User could not be found')
end
it 'returns error when token does not belong to the current user' do
result = described_class.new(create(:user), state: logout_state).execute
expect(result).to eq(status: :error, message: 'User could not be found')
end
context 'when token is valid' do
it 'returns success' do
result = described_class.new(user, state: logout_state).execute
expect(result).to include(status: :success)
end
context 'when OAuth session return_to param is nil' do
it 'returns the Geo node URL associated with OAuth application to redirect the user back' do
oauth_session = Gitlab::Geo::OauthSession.new(access_token: access_token.token, return_to: nil)
logout_state = oauth_session.generate_logout_state
result = described_class.new(user, state: logout_state).execute
expect(result).to include(return_to: node.url)
end
end
context 'when OAuth session return_to param is empty' do
it 'returns the Geo node URL associated with OAuth application to redirect the user back' do
oauth_session = Gitlab::Geo::OauthSession.new(access_token: access_token.token, return_to: '')
logout_state = oauth_session.generate_logout_state
result = described_class.new(user, state: logout_state).execute
expect(result).to include(return_to: node.url)
end
end
context 'when OAuth session return_to param is set' do
it 'returns the fullpath to the Geo node to redirect the user back' do
result = described_class.new(user, state: logout_state).execute
expect(result).to include(return_to: "#{node.url.chomp('/')}/project/test")
end
it 'replaces the host with the Geo node associated with OAuth application' do
oauth_return_to = 'http://fake-secondary/project/test'
oauth_session = Gitlab::Geo::OauthSession.new(access_token: access_token.token, return_to: oauth_return_to)
logout_state = oauth_session.generate_logout_state
result = described_class.new(user, state: logout_state).execute
expect(result).to include(return_to: "#{node.url.chomp('/')}/project/test")
end
it 'handles leading and trailing slashes correctly on return_to path' do
oauth_return_to = '//project/test'
oauth_session = Gitlab::Geo::OauthSession.new(access_token: access_token.token, return_to: oauth_return_to)
logout_state = oauth_session.generate_logout_state
result = described_class.new(user, state: logout_state).execute
expect(result).to include(return_to: "#{node.url.chomp('/')}/project/test")
end
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment