Authentication with OAuth2 - Logout
Part 21 of building a Rails 7 application
A user can now log in to the application, the logical next step would be to allow them to log out as well.
I'll need a new route for this. Performing a delete
on /sessions/logout
will send the user to the SessionsController
destroy
method.
config/routes.rb
Rails.application.routes.draw do
# Authentication
get '/auth/:provider/callback' => 'sessions#omniauth'
delete '/sessions/logout' => 'sessions#destroy'
...
end
The SessionsController
was added in the last blog, I just need the destroy
method now:
app/controllers/sessions_controller.rb
def destroy
session[:user_id] = nil
redirect_to(:root, status: :see_other)
end
This simply deletes the user_id
from the session and redirects the user to the root login page. Because the user_id
is now nil they will be expected to log in again before being able to access any restricted pages.
I'll add some controller tests to make sure all of this works as expected:
test/controllers/sessions_controller_test.rb
require 'test_helper'
class SessionsControllerTest < ActionDispatch::IntegrationTest
test 'valid user' do
OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new({
provider: 'google_oauth2',
uid: '12345',
info: {
first_name: 'Saul',
last_name: 'Goodman',
email: 'saul.goodman@example.com'
}
}
)
get '/auth/google_oauth2/callback'
assert_redirected_to dashboard_url
end
test 'invalid user' do
OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new({
provider: 'google_oauth2',
uid: 'wrong_uid',
info: {
first_name: 'Saul',
last_name: 'Goodman',
email: 'saul.goodman@example.com'
}
}
)
get '/auth/google_oauth2/callback'
assert_redirected_to :root
end
test 'should destroy session' do
authenticate
delete sessions_logout_url
assert_redirected_to :root
assert_nil(session[:user_id], 'Log out did not clear session user_id')
end
end
Here you can see that I've tested both possibilities for the Login action as well as the new Logout action too. For Logout I assert that the user is redirected to the root page and that the user_id
in the session is now nil.
Finally we need a way for the user to trigger this Logout action. I already have a UserProfile
component. It would seem logical to add a link to that. Here's what it looks like in Storybook:
Before making this link work there is one other thing I can do now that I'm using an actual User that is authenticated with Google. One of the attributes that comes back when logging in via Google OAuth2 is an image URL. I'll change the way I display the user image based on whether that is available:
app/components/user_profile/component.rb
def picture_url
@user.picture_url.presence || gravatar_url
end
private
def gravatar_url
"https://www.gravatar.com/avatar/#{gravatar_hash}?d=retro"
end
Now I need to change where I'm rendering that picture so it uses the new picture_url
method. Note also that in order to display the Google image link properly I need to set the referrerpolicy
on this tag to be no-referrer
. This is due to some recent changes in how Chrome handles referrer policy.
app/components/user_profile/component.html.erb
<div id="user_profile--image">
<%= image_tag(picture_url, class: 'inline-block h-12 w-12 rounded-full', alt: "#{@user.full_name}", referrerpolicy: 'no-referrer') %>
</div>
Here's what the UserProfile component looks like now after logging in using my own user account:
Adding the Logout link is next. First I can add a method to the component that will generate the link for me. Note that I need a delete
method here as that is what my route specifies. By default a link_to
will produce a get
.
app/components/user_profile/component.rb
def logout_link
link_to(
sessions_logout_path,
id: 'user_profile--logout',
data: { turbo_method: :delete },
class: 'inline-flex items-center text-xs font-medium text-gray-300 group-hover:text-gray-200'
) do
'Logout'
end
end
Lastly I add some more tests to my component for these new features:
test/components/user_profile_component_test.rb
test 'renders gravatar image' do
assert_equal(
%(<img class="inline-block h-12 w-12 rounded-full" alt="Saul Goodman" referrerpolicy="no-referrer" src="https://www.gravatar.com/avatar/57595bd63983cd9dae1f8ffe9d286c52?d=retro">),
render_inline(UserProfile::Component.new(user: @user)).css('#user_profile--image img').to_html
)
end
test 'renders picture URL image' do
user = users(:saul).tap { |u| u.picture_url = 'http://picture.com' }
assert_equal(
%(<img class="inline-block h-12 w-12 rounded-full" alt="Saul Goodman" referrerpolicy="no-referrer" src="http://picture.com">),
render_inline(UserProfile::Component.new(user: user)).css('#user_profile--image img').to_html
)
end
test 'renders logout link' do
assert_equal(
%(<a id="user_profile--logout" data-turbo-method="delete" class="inline-flex items-center text-xs font-medium text-gray-300 group-hover:text-gray-200" href="/sessions/logout">Logout</a>),
render_inline(UserProfile::Component.new(user: @user)).css('#user_profile--logout').to_html
)
end
And that's it for User management for now. One side effect of adding the current_user
help method in the controller on the previous blog is that the "Audited" gem is now capturing the user that is performing actions too: