Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement the logout button #2906

Merged
merged 10 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions lib/livebook/config.ex
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
defmodule Livebook.Config do
alias Livebook.FileSystem

@type authentication_mode :: :token | :password | :disabled

@type authentication ::
%{mode: :password, secret: String.t()}
| %{mode: :token, secret: String.t()}
Expand Down Expand Up @@ -68,7 +66,7 @@ defmodule Livebook.Config do
@doc """
Returns the authentication configuration.
"""
@spec authentication() :: authentication_mode()
@spec authentication() :: authentication()
def authentication() do
case Application.fetch_env!(:livebook, :authentication) do
{:password, password} -> %{mode: :password, secret: password}
Expand Down Expand Up @@ -270,6 +268,19 @@ defmodule Livebook.Config do
module not in @identity_provider_no_id
end

@doc """
Returns if the identity provider supports logout.
"""
@spec logout_enabled?() :: boolean()
def logout_enabled?() do
{_type, module, _key} = Livebook.Config.identity_provider()

identity_logout? =
Code.ensure_loaded?(module) and function_exported?(module, :logout, 2)

authentication().mode != :disabled or identity_logout?
end

@doc """
Returns whether the application is running inside an iframe.
"""
Expand Down
9 changes: 9 additions & 0 deletions lib/livebook/teams/requests.ex
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,15 @@ defmodule Livebook.Teams.Requests do
get("/api/v1/org/identity", %{access_token: access_token}, team)
end

@doc """
Send a request to Livebook Team API to revoke session from given access token.
"""
@spec logout_identity_provider(Team.t(), String.t()) ::
{:ok, String.t()} | {:error, map() | String.t()} | {:transport_error, String.t()}
def logout_identity_provider(team, access_token) do
post("/api/v1/org/identity/revoke", %{access_token: access_token}, team)
end

@doc """
Normalizes errors map into errors for the given schema.
"""
Expand Down
6 changes: 6 additions & 0 deletions lib/livebook/zta.ex
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ defmodule Livebook.ZTA do
"""
@callback authenticate(name(), Plug.Conn.t(), keyword()) :: {Plug.Conn.t(), metadata() | nil}

@doc """
Logouts against the given name.
"""
@callback logout(name(), Phoenix.LiveView.Socket.t()) :: :ok | {:error, String.t()}
@optional_callbacks logout: 2

aleDsz marked this conversation as resolved.
Show resolved Hide resolved
@doc false
def init do
:ets.new(__MODULE__, [:named_table, :public, :set, read_concurrency: true])
Expand Down
2 changes: 1 addition & 1 deletion lib/livebook/zta/basic_auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ defmodule Livebook.ZTA.BasicAuth do
end

@impl true
def authenticate(name, conn, _options) do
def authenticate(name, conn, _opts) do
{username, password} = Livebook.ZTA.get(name)
conn = Plug.BasicAuth.basic_auth(conn, username: username, password: password)

Expand Down
16 changes: 16 additions & 0 deletions lib/livebook/zta/livebook_teams.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ defmodule Livebook.ZTA.LivebookTeams do
end
end

@impl true
aleDsz marked this conversation as resolved.
Show resolved Hide resolved
def logout(name, %{assigns: %{current_user: %{payload: %{"access_token" => token}}}}) do
team = Livebook.ZTA.get(name)

case Teams.Requests.logout_identity_provider(team, token) do
{:ok, _no_content} ->
:ok

{:error, %{"errors" => %{"revoked_at" => _}}} ->
{:error, "You are already logged out. We will redirect you"}
jonatanklosko marked this conversation as resolved.
Show resolved Hide resolved

{:transport_error, reason} ->
{:error, reason}
end
end

defp handle_request(conn, team, %{"teams_identity" => _, "code" => code}) do
with {:ok, access_token} <- retrieve_access_token(team, code),
{:ok, metadata} <- get_user_info(team, access_token) do
Expand Down
2 changes: 1 addition & 1 deletion lib/livebook/zta/pass_through.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule Livebook.ZTA.PassThrough do
end

@impl true
def authenticate(_, conn, _) do
def authenticate(_name, conn, _opts) do
{conn, %{}}
end
end
15 changes: 15 additions & 0 deletions lib/livebook_web/components/layout_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@ defmodule LivebookWeb.LayoutComponents do
to={~p"/settings"}
current={@current_page}
/>
<button
:if={Livebook.Config.logout_enabled?()}
class="h-7 flex items-center text-gray-400 hover:text-white border-l-4 border-transparent hover:border-white"
aria-label="logout"
phx-click="logout"
>
<.remix_icon
icon="logout-box-line"
class="text-lg leading-6 w-[56px] flex justify-center"
/>
<span class="text-sm font-medium">
Logout
</span>
</button>
</div>
<.hub_section hubs={@saved_hubs} current_page={@current_page} />
</div>
Expand All @@ -126,6 +140,7 @@ defmodule LivebookWeb.LayoutComponents do
Shut Down
</span>
</button>

<button
class="mt-6 flex items-center group border-l-4 border-transparent"
aria_label="user profile"
Expand Down
11 changes: 11 additions & 0 deletions lib/livebook_web/controllers/auth_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ defmodule LivebookWeb.AuthController do
end
end

def logout(conn, _params) do
if get_session(conn, :user_id) do
conn
|> configure_session(renew: true)
|> clear_session()
|> render("logout.html")
else
redirect_to(conn)
end
end

defp render_form_error(conn, authentication_mode) do
errors = [{"%{authentication_mode} is invalid", [authentication_mode: authentication_mode]}]

Expand Down
18 changes: 18 additions & 0 deletions lib/livebook_web/controllers/auth_html/logout.html.heex
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<div class="h-screen w-full px-4 py-8 bg-gray-900 flex justify-center items-center">
<div class="max-w-[400px] w-full flex flex-col">
<a href={~p"/"} class="mb-2 -ml-2">
<img src={~p"/images/logo.png"} height="96" width="96" alt="livebook" />
</a>
<div class="mb-2 text-xl text-gray-100 font-medium">
You have been logged out
</div>

<div class="mb-8 text-sm text-gray-200">
Thank you for using <strong>Livebook</strong>
</div>

<div class="text-gray-50 w-full">
<.button navigate={~p"/"}>Sign in back</.button>
</div>
</div>
</div>
27 changes: 27 additions & 0 deletions lib/livebook_web/live/hooks/sidebar_hook.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
defmodule LivebookWeb.SidebarHook do
use LivebookWeb, :verified_routes
require Logger

import Phoenix.Component
Expand All @@ -17,6 +18,8 @@ defmodule LivebookWeb.SidebarHook do
|> attach_hook(:hubs, :handle_info, &handle_info/2)
|> attach_hook(:shutdown, :handle_info, &handle_info/2)
|> attach_hook(:shutdown, :handle_event, &handle_event/3)
|> attach_hook(:logout, :handle_info, &handle_info/2)
|> attach_hook(:logout, :handle_event, &handle_event/3)

{:cont, socket}
end
Expand All @@ -25,6 +28,15 @@ defmodule LivebookWeb.SidebarHook do
{:halt, put_flash(socket, :info, "Livebook is shutting down. You can close this page.")}
end

defp handle_info(:logout, socket) do
{_type, module, _key} = Livebook.Config.identity_provider()

case module.logout(LivebookWeb.ZTA, socket) do
:ok -> {:halt, redirect(socket, to: ~p"/logout")}
{:error, reason} -> {:cont, put_flash(socket, :error, reason)}
end
end

@connection_events ~w(hub_connected hub_changed hub_deleted)a

defp handle_info(event, socket) when elem(event, 0) in @connection_events do
Expand Down Expand Up @@ -59,5 +71,20 @@ defmodule LivebookWeb.SidebarHook do
)}
end

defp handle_event("logout", _params, socket) do
on_confirm = fn socket ->
Phoenix.PubSub.broadcast(Livebook.PubSub, "sidebar", :logout)
put_flash(socket, :info, "Livebook is logging out. You will be redirected soon.")
end

{:halt,
confirm(socket, on_confirm,
title: "Log out",
description: "Are you sure you want to log out Livebook now?",
confirm_text: "Log out",
confirm_icon: "logout-box-line"
)}
end

defp handle_event(_event, _params, socket), do: {:cont, socket}
end
4 changes: 3 additions & 1 deletion lib/livebook_web/plugs/user_plug.ex
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ defmodule LivebookWeb.UserPlug do
we get possibly updated `user_data` from `connect_params`.
"""
def build_current_user(session, user_data_override \\ nil) do
identity_data = Map.new(session["identity_data"], fn {k, v} -> {Atom.to_string(k), v} end)
identity_data =
Map.new(session["identity_data"] || %{}, fn {k, v} -> {Atom.to_string(k), v} end)

attrs = user_data_override || session["user_data"] || %{}

attrs =
Expand Down
5 changes: 5 additions & 0 deletions lib/livebook_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ defmodule LivebookWeb.Router do
post "/", AuthController, :authenticate
end

scope "/", LivebookWeb do
pipe_through [:browser]
get "/logout", AuthController, :logout
end

defp within_iframe_secure_headers(conn, _opts) do
if Livebook.Config.within_iframe?() do
delete_resp_header(conn, "x-frame-options")
Expand Down
54 changes: 54 additions & 0 deletions test/livebook_teams/zta/livebook_teams_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,58 @@ defmodule Livebook.ZTA.LivebookTeamsTest do
"Failed to authenticate with Livebook Teams: you do not belong to this org"
end
end

describe "logout/2" do
test "revoke access token from Livebook Teams", %{conn: conn, node: node, test: test} do
# Step 1: Get redirected to Livebook Teams
conn = init_test_session(conn, %{})
{conn, nil} = LivebookTeams.authenticate(test, conn, [])

[_, location] = Regex.run(~r/URL\("(.*?)"\)/, html_response(conn, 200))
uri = URI.parse(location)
assert uri.path == "/identity/authorize"
assert %{"code" => code} = URI.decode_query(uri.query)

erpc_call(node, :allow_auth_request, [code])

# Step 2: Emulate the redirect back with the code for validation
conn =
build_conn(:get, "/", %{teams_identity: "", code: code})
|> init_test_session(%{})

assert {conn, %{id: _id, name: _, email: _, payload: %{"access_token" => _}} = metadata} =
LivebookTeams.authenticate(test, conn, [])

assert redirected_to(conn, 302) == "/"

# Step 3: Confirm the token/metadata is valid for future requests
conn =
build_conn(:get, "/")
|> init_test_session(%{identity_data: metadata})

assert {%{halted: false}, ^metadata} = LivebookTeams.authenticate(test, conn, [])

# Step 4: Revoke the token and the metadata will be invalid for future requests
user =
metadata.id
|> Livebook.Users.User.new()
|> Livebook.Users.User.changeset(metadata)
|> Ecto.Changeset.apply_changes()

conn =
build_conn(:get, "/")
|> init_test_session(%{identity_data: metadata})
|> assign(:current_user, user)

assert LivebookTeams.logout(test, conn) == :ok
jonatanklosko marked this conversation as resolved.
Show resolved Hide resolved

# Step 5: If we try to revoke again, it should fail
assert {:error, _} = LivebookTeams.logout(test, conn)

# Step 6: It we try to authenticate again, it should redirect to Teams
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
assert conn.halted
assert html_response(conn, 200) =~ "window.location.href = "
end
end
end
Loading