Authentication with OAuth2 - Logout

Photo by FLY:D on Unsplash

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:

image.png

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:

image.png

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:

image.png

Useful references:

developer.chrome.com/blog/referrer-policy-n..