Skip to content

Commit

Permalink
feat: Keycloak token refresh (#1028)
Browse files Browse the repository at this point in the history
* fix: remove unneeded KEYCLOAK_IDP_HINT

We set this inside Keycloak now, so it's not required here.

* feat: session and idle timeouts

* chore: setup keycloak locally

* fix: use cognito by default for tests

* chore: create/update stop within live session

---------

Co-authored-by: Paul Swartz <paul@paulswartz.net>
  • Loading branch information
Whoops and paulswartz authored Nov 13, 2024
1 parent eaba71a commit a9fbf94
Show file tree
Hide file tree
Showing 16 changed files with 172 additions and 51 deletions.
7 changes: 6 additions & 1 deletion .envrc.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,9 @@ export DATABASE_PASSWORD=postgres
export ARROW_DOMAIN=https://arrow.mbta.com
export ARROW_API_KEY=
export AWS_ACCESS_KEY_ID=
export AWS_SECRET_ACCESS_KEY=
export AWS_SECRET_ACCESS_KEY=
export KEYCLOAK_ISSUER=https://login-dev.mbtace.com/auth/realms/MBTA
export KEYCLOAK_API_BASE=https://login-dev.mbtace.com/auth/admin/realms/MBTA/
export KEYCLOAK_CLIENT_ID=arrow-dev
export KEYCLOAK_CLIENT_UUID=bd84a8e2-2fce-4c7a-bfe3-3c7ac71fb5b2
export KEYCLOAK_CLIENT_SECRET=
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
- `cp .envrc.example .envrc`
- Update `.envrc` with your local Postgres username and password
- Update `.envrc` with your AWS credentials or ensure they are available in your shell
- Update `.envrc` with the Arrow Dev Keycloak client secret (found in 1Password)
- `mix ecto.setup`
- `brew install chromedriver`
- Add your Arrow API key from https://arrow.mbta.com/mytoken to `.envrc`
Expand Down
20 changes: 16 additions & 4 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ config :arrow,
# map cognito groups to roles
"arrow-admin" => "admin"
},
ueberauth_provider: :cognito,
api_login_module: ArrowWeb.TryApiTokenAuth.Cognito,
ueberauth_provider: :keycloak,
api_login_module: ArrowWeb.TryApiTokenAuth.Keycloak,
required_roles: %{
view_disruption: ["read-only", "admin"],
create_disruption: ["admin"],
Expand Down Expand Up @@ -103,14 +103,26 @@ config :tailwind,
cd: Path.expand("../assets", __DIR__)
]

config :arrow, ArrowWeb.AuthManager, issuer: "arrow"
# 12 hours in seconds
max_session_time = 12 * 60 * 60

config :arrow, ArrowWeb.AuthManager,
issuer: "arrow",
max_session_time: max_session_time,
# 30 minutes
idle_time: 30 * 60

config :ueberauth, Ueberauth,
providers: [
cognito: {Ueberauth.Strategy.Cognito, []},
keycloak:
{Ueberauth.Strategy.Oidcc,
issuer: :keycloak_issuer, userinfo: true, uid_field: "email", scopes: ~w"openid email"}
issuer: :keycloak_issuer,
userinfo: true,
uid_field: "email",
scopes: ~w"openid email",
authorization_params: %{max_age: "#{max_session_time}"},
authorization_params_passthrough: ~w"prompt login_hint"}
]

config :ueberauth, Ueberauth.Strategy.Cognito,
Expand Down
7 changes: 0 additions & 7 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,6 @@ if is_binary(keycloak_issuer) and not is_test? do
client_secret: System.fetch_env!("KEYCLOAK_CLIENT_SECRET")
]

keycloak_opts =
if keycloak_idp = System.get_env("KEYCLOAK_IDP_HINT") do
Keyword.put(keycloak_opts, :authorization_params, %{kc_idp_hint: keycloak_idp})
else
keycloak_opts
end

config :ueberauth_oidcc,
issuers: [
%{
Expand Down
4 changes: 3 additions & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ config :arrow,
shape_storage_enabled?: false,
shape_storage_request_fn: {Arrow.Mock.ExAws.Request, :request},
gtfs_archive_storage_enabled?: false,
gtfs_archive_storage_request_fn: {Arrow.Mock.ExAws.Request, :request}
gtfs_archive_storage_request_fn: {Arrow.Mock.ExAws.Request, :request},
ueberauth_provider: :cognito,
api_login_module: ArrowWeb.TryApiTokenAuth.Cognito

# Configure your database
config :arrow, Arrow.Repo,
Expand Down
18 changes: 18 additions & 0 deletions lib/arrow/stops.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@ defmodule Arrow.Stops do
"""
def get_stop!(id), do: Repo.get!(Stop, id)

@doc """
Gets a single stop by stop_id.
Returns nil if no stop exists with the given stop_id.
## Examples
iex> get_stop_by_stop_id("123")
%Stop{}
iex> get_stop_by_stop_id("456")
nil
"""
def get_stop_by_stop_id(stop_id) do
Repo.get_by(Stop, stop_id: stop_id)
end

@doc """
Creates a stop.
Expand Down
6 changes: 5 additions & 1 deletion lib/arrow/ueberauth/strategy/fake.ex
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ defmodule Arrow.Ueberauth.Strategy.Fake do

@impl Ueberauth.Strategy
def extra(_conn) do
%Ueberauth.Auth.Extra{raw_info: %{}}
%Ueberauth.Auth.Extra{
raw_info: %{
"iat" => System.system_time(:second)
}
}
end

@impl Ueberauth.Strategy
Expand Down
23 changes: 23 additions & 0 deletions lib/arrow_web/auth_manager.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ defmodule ArrowWeb.AuthManager do

use Guardian, otp_app: :arrow

def max_session_time do
Application.get_env(:arrow, __MODULE__)[:max_session_time]
end

def idle_time do
Application.get_env(:arrow, __MODULE__)[:idle_time]
end

@impl true
def subject_for_token(resource, _claims) do
{:ok, resource}
Expand All @@ -14,4 +22,19 @@ defmodule ArrowWeb.AuthManager do
end

def resource_from_claims(_), do: {:error, :invalid_claims}

@impl true
def verify_claims(%{"iat" => iat, "auth_time" => auth_time} = claims, _opts) do
now = System.system_time(:second)
# auth_time is when the user entered their password at the SSO provider
auth_time_expires = auth_time + max_session_time()
# iat is when the token was issued
iat_expires = iat + idle_time()
# did either timeout expire?
if min(auth_time_expires, iat_expires) < now do
{:error, {:auth_expired, claims["sub"]}}
else
{:ok, claims}
end
end
end
24 changes: 22 additions & 2 deletions lib/arrow_web/auth_manager/error_handler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,28 @@ defmodule ArrowWeb.AuthManager.ErrorHandler do
@behaviour Guardian.Plug.ErrorHandler

@impl Guardian.Plug.ErrorHandler
def auth_error(conn, {_type, _reason}, _opts) do
def auth_error(conn, error, _opts) do
provider = Application.get_env(:arrow, :ueberauth_provider)
Controller.redirect(conn, to: Routes.auth_path(conn, :request, "#{provider}"))
auth_params = auth_params_for_error(error)
Controller.redirect(conn, to: Routes.auth_path(conn, :request, "#{provider}", auth_params))
end

def auth_params_for_error({:invalid_token, {:auth_expired, sub}}) do
# if we know the user who was logged in before, provide that upstream to simplify
# logging in
%{
prompt: "login",
login_hint: sub
}
end

def auth_params_for_error({:unauthenticated, _}) do
%{}
end

def auth_params_for_error(_) do
%{
prompt: "login"
}
end
end
18 changes: 18 additions & 0 deletions lib/arrow_web/auth_manager/pipeline.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,22 @@ defmodule ArrowWeb.AuthManager.Pipeline do
plug(Guardian.Plug.VerifySession, claims: %{"typ" => "access"})
plug(Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"})
plug(Guardian.Plug.LoadResource, allow_blank: true)
plug :refresh_idle_token

@doc """
Refreshes the token with each request.
This allows us to use the `iat` time in the token as an idle timeout.
"""
def refresh_idle_token(conn, _opts) do
old_token = Guardian.Plug.current_token(conn)

case ArrowWeb.AuthManager.refresh(old_token) do
{:ok, _old, {new_token, _new_claims}} ->
Guardian.Plug.put_session_token(conn, new_token)

_ ->
conn
end
end
end
16 changes: 13 additions & 3 deletions lib/arrow_web/controllers/auth_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ defmodule ArrowWeb.AuthController do

groups = Map.get(auth.credentials.other, :groups, [])

auth_time = Map.get(auth.extra.raw_info, "auth_time", auth.extra.raw_info["iat"])

roles =
Enum.flat_map(groups, fn group ->
case @cognito_groups[group] do
Expand All @@ -39,6 +41,7 @@ defmodule ArrowWeb.AuthController do
ArrowWeb.AuthManager,
username,
%{
auth_time: auth_time,
# all cognito users have read-only access
roles: roles ++ ["read-only"]
},
Expand All @@ -49,8 +52,13 @@ defmodule ArrowWeb.AuthController do

def callback(%{assigns: %{ueberauth_auth: %{provider: :keycloak} = auth}} = conn, _params) do
username = auth.uid
expiration = auth.credentials.expires_at
current_time = System.system_time(:second)

auth_time =
Map.get(
auth.extra.raw_info.claims,
"auth_time",
auth.extra.raw_info.claims["iat"]
)

roles = auth.extra.raw_info.userinfo["roles"] || []

Expand All @@ -66,14 +74,16 @@ defmodule ArrowWeb.AuthController do
end

conn
|> configure_session(drop: true)
|> put_session(:logout_url, logout_url)
|> Guardian.Plug.sign_in(
ArrowWeb.AuthManager,
username,
%{
auth_time: auth_time,
roles: roles
},
ttl: {expiration - current_time, :seconds}
ttl: {1, :minute}
)
|> redirect(to: Routes.disruption_path(conn, :index))
end
Expand Down
35 changes: 20 additions & 15 deletions lib/arrow_web/live/stop_live/stop_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,6 @@ defmodule ArrowWeb.StopViewLive do
{:ok, socket}
end

def handle_changeset(socket, changeset) do
case Ecto.Changeset.apply_action(changeset, :validate) do
{:ok, _} ->
{:noreply, assign(socket, form: to_form(changeset), trigger_submit: true)}

{:error, applied_changeset} ->
{:noreply, assign(socket, form: to_form(applied_changeset), trigger_submit: false)}
end
end

def handle_event("validate", %{"stop" => stop_params}, socket) do
form = Stops.change_stop(socket.assigns.stop, stop_params) |> to_form(action: :validate)

Expand All @@ -138,14 +128,29 @@ defmodule ArrowWeb.StopViewLive do

def handle_event("edit", %{"stop" => stop_params}, socket) do
stop = Stops.get_stop!(socket.assigns.stop.id)
changeset = Stops.change_stop(stop, stop_params)

handle_changeset(socket, changeset)
case Arrow.Stops.update_stop(stop, stop_params) do
{:ok, _stop} ->
{:noreply,
socket
|> put_flash(:info, "Stop edited successfully")
|> redirect(to: ~p"/stops")}

{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end

def handle_event("create", %{"stop" => stop_params}, socket) do
changeset = Stops.change_stop(%Stop{}, stop_params)

handle_changeset(socket, changeset)
case Arrow.Stops.create_stop(stop_params) do
{:ok, _stop} ->
{:noreply,
socket
|> put_flash(:info, "Stop created successfully")
|> redirect(to: ~p"/stops")}

{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
end
9 changes: 9 additions & 0 deletions test/arrow/stops_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -171,5 +171,14 @@ defmodule Arrow.StopsTest do
stop = stop_fixture()
assert %Ecto.Changeset{} = Stops.change_stop(stop)
end

test "get_stop_by_stop_id/1 returns stop when found" do
stop = stop_fixture()
assert Stops.get_stop_by_stop_id(stop.stop_id) == stop
end

test "get_stop_by_stop_id/1 returns nil when stop not found" do
assert Stops.get_stop_by_stop_id("nonexistent") == nil
end
end
end
2 changes: 1 addition & 1 deletion test/arrow_web/auth_manager/error_handler_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ defmodule ArrowWeb.AuthManager.ErrorHandlerTest do
|> init_test_session(%{})
|> ArrowWeb.AuthManager.ErrorHandler.auth_error({:some_type, :reason}, [])

assert html_response(conn, 302) =~ "\"/auth/#{provider}\""
assert html_response(conn, 302) =~ "\"/auth/#{provider}?prompt=login\""
end
end
end
6 changes: 2 additions & 4 deletions test/arrow_web/controllers/auth_controller_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ defmodule ArrowWeb.Controllers.AuthControllerTest do
other: %{id_token: "id_token"}
},
extra: %{
raw_info: %{
raw_info: %UeberauthOidcc.RawInfo{
userinfo: %{
"roles" => ["admin"]
}
Expand Down Expand Up @@ -69,9 +69,7 @@ defmodule ArrowWeb.Controllers.AuthControllerTest do
other: %{id_token: "id_token"}
},
extra: %{
raw_info: %{
userinfo: %{}
}
raw_info: %UeberauthOidcc.RawInfo{}
}
}

Expand Down
Loading

0 comments on commit a9fbf94

Please sign in to comment.