Skip to content

Commit

Permalink
Merge pull request #413 from mbta/move_to_keycloak
Browse files Browse the repository at this point in the history
Migrate to keycloak
  • Loading branch information
Adzz authored Nov 5, 2024
2 parents 84f22b8 + ad5ab68 commit a78368a
Show file tree
Hide file tree
Showing 26 changed files with 372 additions and 251 deletions.
3 changes: 2 additions & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
[
import_deps: [:phoenix],
inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"]
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
]
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ npm-debug.log
# we ignore priv/static. You may want to comment
# this depending on your deployment strategy.
/priv/static/
.envrc
/**/*.DS_STORE
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ RUN mix do compile --force, phx.digest, release
# Finally, use an Alpine container for the runtime environment
FROM alpine:3.19.4

RUN apk add --update libstdc++ ncurses-libs bash curl dumb-init \
RUN apk add --update libstdc++ ncurses-libs bash curl dumb-init ca-certificates \
&& apk upgrade \
&& rm -rf /var/cache/apk

Expand Down
4 changes: 2 additions & 2 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ config :logger, :console,
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason

# Placeholder for Cognito authentication, defined for real in environment configs
# Placeholder for Keycloak authentication, defined for real in environment configs
config :ueberauth, Ueberauth,
providers: [
cognito: {DocumentViewerWeb.Ueberauth.Strategy.Fake, []}
keycloak: {DocumentViewerWeb.Ueberauth.Strategy.Fake, []}
]

# Import environment specific config. This must remain at the bottom
Expand Down
14 changes: 8 additions & 6 deletions config/prod.exs
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,6 @@ config :document_viewer, DocumentViewerWeb.Endpoint,
# Do not print debug messages in production
config :logger, level: :info

# Configure Ueberauth to use Cognito
config :ueberauth, Ueberauth,
providers: [
cognito: {Ueberauth.Strategy.Cognito, []}
]

# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
Expand Down Expand Up @@ -60,3 +54,11 @@ config :ueberauth, Ueberauth,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.

config(:ueberauth, Ueberauth,
providers: [
keycloak:
{Ueberauth.Strategy.Oidcc,
issuer: :keycloak_issuer, userinfo: true, uid_field: "email", scopes: ~w(openid email)}
]
)
17 changes: 10 additions & 7 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ end
if config_env() == :prod do
config :document_viewer, DocumentViewerWeb.Endpoint,
secret_key_base: System.get_env("SECRET_KEY_BASE")

keycloak_opts = [
client_id: System.fetch_env!("KEYCLOAK_CLIENT_ID"),
client_secret: System.fetch_env!("KEYCLOAK_CLIENT_SECRET")
]

config(:ueberauth_oidcc,
issuers: [%{name: :keycloak_issuer, issuer: System.fetch_env!("KEYCLOAK_ISSUER")}],
providers: [keycloak: keycloak_opts]
)
end

if guardian_secret_key = System.get_env("GUARDIAN_SECRET_KEY") do
config :document_viewer, DocumentViewerWeb.AuthManager, secret_key: guardian_secret_key
end

config :ueberauth, Ueberauth.Strategy.Cognito,
auth_domain: System.get_env("COGNITO_DOMAIN"),
client_id: System.get_env("COGNITO_CLIENT_ID"),
client_secret: System.get_env("COGNITO_CLIENT_SECRET"),
user_pool_id: System.get_env("COGNITO_USER_POOL_ID"),
aws_region: System.get_env("COGNITO_AWS_REGION")
2 changes: 1 addition & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ config :document_viewer, DocumentViewerWeb.Endpoint,
config :document_viewer, DocumentViewerWeb.AuthManager, secret_key: "test key"

# Print only warnings and errors during test
config :logger, level: :warn
config :logger, level: :warning
7 changes: 7 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=

## Single Sign-on configuration.
### Used for local dev, cloud environments make use of Terraform env vars
# export KEYCLOAK_ISSUER=
# export KEYCLOAK_CLIENT_ID=
# export KEYCLOAK_CLIENT_SECRET=
# export KEYCLOAK_IDP_HINT=oidc-mbta-entraid
13 changes: 12 additions & 1 deletion lib/document_viewer_web.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ defmodule DocumentViewerWeb do
below. Instead, define any helper function in modules
and import those modules here.
"""
def static_paths, do: ~w(css fonts images js favicon.ico robots.txt)

def controller do
quote do
use Phoenix.Controller, namespace: DocumentViewerWeb

import Plug.Conn
import DocumentViewerWeb.Gettext
alias DocumentViewerWeb.Router.Helpers, as: Routes
unquote(verified_routes())
end
end

Expand Down Expand Up @@ -69,6 +70,16 @@ defmodule DocumentViewerWeb do
import DocumentViewerWeb.ErrorHelpers
import DocumentViewerWeb.Gettext
alias DocumentViewerWeb.Router.Helpers, as: Routes
unquote(verified_routes())
end
end

def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: DocumentViewerWeb.Endpoint,
router: DocumentViewerWeb.Router,
statics: DocumentViewerWeb.static_paths()
end
end

Expand Down
33 changes: 32 additions & 1 deletion lib/document_viewer_web/auth_manager.ex
Original file line number Diff line number Diff line change
@@ -1,14 +1,45 @@
defmodule DocumentViewerWeb.AuthManager do
@moduledoc """
Custom Guardian auth manager.
Custom Guardian auth manager. Guardian is the library responsible for creating tokens and
parsing / verifying them out of requests. We use JWTs which are the default tokens
Guardian expects. This module implements callbacks that get called when a token is being
created or read. The callbacks do things like let us add data into the token or use data
pulled out of the token to do things.
"""

use Guardian, otp_app: :document_viewer

@impl Guardian
@doc """
To create a token we call Guardian.encode_and_sign/4 passing it a resource as the second
arg and claims as the optional 3rd arg. That resource is used to build out the "Registered
claims" of the JWT. Registered claims are standardized bits of data we put into a token
that get used by whoever receives it to do things like check the token is still valid and
find the user the token should belong to.
https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims#registered-claims
`Guardian.encode_and_sign/4` calls this callback as part of building a valid token, putting
the resource we give to encode_and_sign/4 as the first argument and the optional claims as
the second arg.
Usually the resource might be something like a `User` struct, and we might choose to use the
ID of that user as the "sub" (subject) for the token, so that we can later parse the ID
out of the token and fetch the correct user with it. In this app there are no Users so to
speak so our resource is just a username and we don't really do anything with it.
"""
def subject_for_token(resource, _claims) do
{:ok, resource}
end

@impl Guardian
@doc """
This callback gets called when we are parsing / validating a token. It gets given the claims
found in there and it's on us to do something with them. Normally you might expect an ID
as the "sub" field which could be used to find a user, erroring if we cannot find them.
This app does not have a database and therefore does not have a user that we can look up
so we don't do anything but check that there is a sub.
"""
def resource_from_claims(%{"sub" => username}) do
{:ok, username}
end
Expand Down
11 changes: 6 additions & 5 deletions lib/document_viewer_web/auth_manager/error_handler.ex
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
defmodule DocumentViewerWeb.AuthManager.ErrorHandler do
@moduledoc """
Custom Guardian error handler.
Custom Guardian error handler. If we ever get a token that does not seem valid this gets
called.
"""

@behaviour Guardian.Plug.ErrorHandler

alias DocumentViewerWeb.Router.Helpers
use DocumentViewerWeb, :verified_routes

@impl Guardian.Plug.ErrorHandler
def auth_error(conn, {_type, _reason}, _opts) do
Phoenix.Controller.redirect(conn, to: Helpers.auth_path(conn, :request, "cognito"))
def auth_error(conn, {_type, reason}, _opts)
when reason in [:unauthenticated, :token_expired] do
Phoenix.Controller.redirect(conn, to: ~p"/auth/keycloak")
end
end
15 changes: 15 additions & 0 deletions lib/document_viewer_web/auth_manager/pipeline.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,27 @@ defmodule DocumentViewerWeb.AuthManager.Pipeline do
Custom Guardian pipeline.
"""

# This sets up Guardian so that we can build / parse tokens found in a request as per
# the DocumentViewerWeb.AuthManager, and ensures errors are handled as per ErrorHandler.
use Guardian.Plug.Pipeline,
otp_app: :document_viewer,
module: DocumentViewerWeb.AuthManager,
error_handler: DocumentViewerWeb.AuthManager.ErrorHandler

# The first plug checks the session for a JWT token...
plug(Guardian.Plug.VerifySession, claims: %{"typ" => "access"})
# If a token was not found in the session, we check the headers here.
# If a token is found thereafter it can be accessed via Guardian.Plug.current_token().
# The `claims: %{"typ" => "access"}` specifies that the token should be an access token
# (as opposed to a refresh token for example).
plug(Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"})
# This takes the token found from above (if one is found) and calls
# our resource_from_claims/1 callback defined in DocumentViewerWeb.AuthManager. That would
# let us find the relevant user if we had one, but generally lets us inspect the claims
# found in the token and do something with them.
plug(Guardian.Plug.LoadResource, allow_blank: true)

# Finally if we never found a token above this plug will call the error handler
# `DocumentViewerWeb.AuthManager.ErrorHandler` above with :unauthenticated as a reason.
plug(Guardian.Plug.EnsureAuthenticated)
end
71 changes: 45 additions & 26 deletions lib/document_viewer_web/controllers/auth_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,65 @@ defmodule DocumentViewerWeb.AuthController do
use DocumentViewerWeb, :controller

require Logger
@document_viewer_role "document-viewer-admin"
def document_viewer_role, do: @document_viewer_role

# This handles implements the request/2 function that Plug will call. That implementation
# will create a valid Keycloak request and ensure that callback/2 below gets called when
# we return from keycloak to here.
plug(Ueberauth)

alias DocumentViewerWeb.{AuthManager, UserActionLogger}
alias DocumentViewerWeb.Router.Helpers
alias DocumentViewerWeb.AuthManager
alias DocumentViewerWeb.UserActionLogger

def request(conn, %{"provider" => provider}) when provider != "cognito" do
send_resp(conn, 404, "Not Found")
end
@doc """
This is called when a user returns from logging in. They'll return with information that
Ueberauth extracts from the request and puts into assigns for us, which we can then use
to ensure the user is allowed to see what they are asking to see.
"""
def callback(
%{
assigns: %{
ueberauth_auth: %Ueberauth.Auth{
uid: username,
credentials: credentials,
extra: extra,
provider: :keycloak
}
}
} = conn,
_params
) do
expiration = credentials.expires_at
current_time = System.system_time(:second)

def callback(conn, %{"provider" => provider}) when provider != "cognito" do
send_resp(conn, 404, "Not Found")
end
client_id = extra.raw_info.claims["aud"]
roles = extra.raw_info.userinfo["resource_access"][client_id]["roles"] || []

def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
username = auth.uid
expiration = auth.credentials.expires_at
credentials = auth.credentials
UserActionLogger.log(username, :login)

current_time = System.system_time(:second)
time_left = expiration - current_time

UserActionLogger.log(username, :login)
if @document_viewer_role in roles do
conn
|> Guardian.Plug.sign_in(AuthManager, username, %{roles: roles}, ttl: {time_left, :seconds})
|> Plug.Conn.put_session(:username, username)
|> redirect(to: ~p"/")
else
Logger.warning("Document viewer role not found in the roles for user: #{roles}")
redirect_to_dotcom(conn)
end
end

conn
|> Guardian.Plug.sign_in(
AuthManager,
username,
%{groups: credentials.other.groups},
ttl: {expiration - current_time, :seconds}
)
|> Plug.Conn.put_session(:username, username)
|> redirect(to: Helpers.query_path(conn, :new))
def callback(%{assigns: %{ueberauth_failure: ueberauth_failure}} = conn, _params) do
log_errors(ueberauth_failure)
redirect_to_dotcom(conn)
end

# If a user gets a failure from Ueberauth, we want to redirect them away from this site.
# Since everything on this site requires authorization, they will get trapped
# in an infinite loop of redirects otherwise.
def callback(%{assigns: %{ueberauth_failure: ueberauth_failure}} = conn, _params) do
log_errors(ueberauth_failure)

defp redirect_to_dotcom(conn) do
conn
|> Guardian.Plug.sign_out(AuthManager, [])
|> redirect(external: "https://www.mbta.com")
Expand Down
2 changes: 1 addition & 1 deletion lib/document_viewer_web/endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ defmodule DocumentViewerWeb.Endpoint do
at: "/",
from: :document_viewer,
gzip: false,
only: ~w(css fonts images js favicon.ico robots.txt)
only: DocumentViewerWeb.static_paths()

# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
Expand Down
29 changes: 0 additions & 29 deletions lib/document_viewer_web/ensure_document_viewer_group.ex

This file was deleted.

Loading

0 comments on commit a78368a

Please sign in to comment.