Skip to content

Commit

Permalink
feat: Keycloak (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
mathcolo authored Jun 28, 2024
1 parent 97d9591 commit 873c6f8
Show file tree
Hide file tree
Showing 22 changed files with 591 additions and 7 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ jobs:
- name: docker compose up
run: docker compose up --wait
- name: ensure running properly
run: docker compose exec --no-TTY orbit wget --spider -S http://localhost:4001/
run: docker compose exec --no-TTY orbit wget --spider -S http://localhost:4001/_health
- name: show docker container logs
run: docker compose logs orbit
if: ${{ !cancelled() }}
10 changes: 9 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import Config
# Application config
config :orbit,
ecto_repos: [Orbit.Repo],
generators: [timestamp_type: :utc_datetime]
generators: [timestamp_type: :utc_datetime],
force_https?: true

# Endpoint config
config :orbit, OrbitWeb.Endpoint,
Expand Down Expand Up @@ -34,6 +35,13 @@ config :phoenix,
# We use logster instead of the default Phoenix logging
logger: false

# Auth
config :ueberauth, Ueberauth,
providers: [
# specified in dev.exs / prod.exs
keycloak: nil
]

# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
22 changes: 21 additions & 1 deletion config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import Config
config :orbit,
# Enable dev routes for dashboard and mailbox
dev_routes: true,
release: "local"
release: "local",
force_https?: false

# Database config
config :orbit, Orbit.Repo,
Expand Down Expand Up @@ -59,6 +60,25 @@ config :phoenix_live_view,
# Include HEEx debug annotations as HTML comments in rendered markup
debug_heex_annotations: true

# Auth
config :orbit, OrbitWeb.Auth.Guardian,
issuer: "orbit",
secret_key: "dev key"

config :ueberauth, Ueberauth,
providers: [
keycloak: {OrbitWeb.Auth.Strategy.FakeOidcc, []}
]

config :ueberauth_oidcc,
providers: [
keycloak: [
issuer: "dev-issuer",
client_id: "dev-client-id",
client_secret: "dev-secret"
]
]

if File.exists?(Path.expand("dev.secret.exs", __DIR__)) do
import_config "dev.secret.exs"
end
15 changes: 15 additions & 0 deletions config/prod.exs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ config :ehmon, :report_mf, {:ehmon, :info_report}
# this tells disksup to use different df flags
config :os_mon, disksup_posix_only: true

# Auth
config :orbit, OrbitWeb.Auth.Guardian, issuer: "orbit"

config :ueberauth, Ueberauth,
providers: [
keycloak:
{Ueberauth.Strategy.Oidcc,
[
issuer: :keycloak_issuer,
userinfo: true,
uid_field: "email",
scopes: ~w"openid email"
]}
]

config :sentry,
# dsn and environment_name are loaded at runtime
enable_source_code_context: true,
Expand Down
20 changes: 19 additions & 1 deletion config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ if config_env() == :prod do
{Orbit.Repo, :add_prod_credentials, []}
end)

# Auth
config :ueberauth_oidcc,
issuers: [
%{
name: :keycloak_issuer,
issuer: System.fetch_env!("KEYCLOAK_ISSUER")
}
],
providers: [
keycloak: [
issuer: :keycloak_issuer,
client_id: System.fetch_env!("KEYCLOAK_CLIENT_ID"),
client_secret: System.fetch_env!("KEYCLOAK_CLIENT_SECRET")
]
]

config :orbit, OrbitWeb.Auth.Guardian, secret_key: System.get_env("GUARDIAN_SECRET_KEY")

# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
Expand All @@ -43,7 +61,7 @@ if config_env() == :prod do
You can generate one by calling: mix phx.gen.secret
"""

host = System.get_env("PHX_HOST") || "example.com"
host = System.fetch_env!("PHX_HOST")
port = String.to_integer(System.get_env("PORT") || "4001")

config :orbit, OrbitWeb.Endpoint,
Expand Down
22 changes: 21 additions & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import Config

config :orbit,
release: "test"
release: "test",
force_https?: false

# Database config
config :orbit, Orbit.Repo,
Expand All @@ -25,3 +26,22 @@ config :logger, level: :warning

# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime

# Auth
config :ueberauth, Ueberauth,
providers: [
keycloak: {OrbitWeb.Auth.Strategy.FakeOidcc, []}
]

config :ueberauth_oidcc,
providers: [
keycloak: [
issuer: "test-issuer",
client_id: "test-client-id",
client_secret: "test-secret"
]
]

config :orbit, OrbitWeb.Auth.Guardian,
issuer: "orbit",
secret_key: "dev key"
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ services:
- DATABASE_HOST=db
- DATABASE_NAME=postgres
- DATABASE_DISABLE_SSL=insecure-yes
- PHX_HOST=localhost
- SECRET_KEY_BASE=/insecure/insecure/insecure/insecure/insecure/insecure/insecure/
- KEYCLOAK_ISSUER=https://login-sandbox.mbtace.com/auth/realms/MBTA
- KEYCLOAK_CLIENT_ID=docker_fake_keycloak_client_id
- KEYCLOAK_CLIENT_SECRET=docker_fake_keycloak_client_secret
ports:
- 4001
28 changes: 28 additions & 0 deletions lib/orbit/authentication/user.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
defmodule Orbit.Authentication.User do
use Ecto.Schema
import Ecto.Changeset

@type t :: %__MODULE__{
id: integer(),
email: String.t(),
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}

schema "users" do
field(:email, :string)
timestamps()
end

def create_changeset(struct, params \\ %{}) do
struct
|> cast(params, [
:email
])
|> validate_required([:email])
|> unique_constraint(
:users,
name: :users_email_index
)
end
end
51 changes: 51 additions & 0 deletions lib/orbit_web/auth/auth.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
defmodule OrbitWeb.Auth.Auth do
alias Orbit.Authentication.User
alias Orbit.Repo
alias Plug.Conn

@spec login(Conn.t(), String.t(), integer(), [String.t()], String.t() | nil) ::
Conn.t()
def login(conn, username, ttl_seconds, _groups, logout_url) do
email = String.downcase(username)

case Repo.get_by(User, email: email) do
nil ->
Repo.insert!(%User{email: email})

u ->
u
end

# We use username (email) as the Guardian resource
conn
|> OrbitWeb.Auth.Guardian.Plug.sign_in(
username,
# claims
%{},
ttl: {ttl_seconds, :seconds}
)
|> Plug.Conn.put_session(:username, username)
|> Plug.Conn.put_session(:logout_url, logout_url)
end

@spec logged_in_user(Conn.t()) :: map() | nil
def logged_in_user(conn) do
if Map.has_key?(conn.assigns, :logged_in_user) do
conn.assigns[:logged_in_user]
else
if email = OrbitWeb.Auth.Guardian.Plug.current_resource(conn) do
email = String.downcase(email)
Repo.get_by(User, email: email)
else
nil
end
end
end

@spec logout(Conn.t()) :: Conn.t()
def logout(conn) do
conn
|> Plug.Conn.assign(:logged_in_user, nil)
|> OrbitWeb.Auth.Guardian.Plug.sign_out()
end
end
41 changes: 41 additions & 0 deletions lib/orbit_web/auth/guardian.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
defmodule OrbitWeb.Auth.Guardian do
use Guardian, otp_app: :orbit

@spec subject_for_token(Guardian.Token.resource(), Guardian.Token.claims()) ::
{:ok, String.t()} | {:error, atom()}
def subject_for_token(resource, _claims) do
sub = resource
{:ok, sub}
end

@spec resource_from_claims(Guardian.Token.claims()) ::
{:ok, Guardian.Token.resource()} | {:error, atom()}
def resource_from_claims(%{"sub" => id}) do
resource = id
{:ok, resource}
end
end

defmodule OrbitWeb.Auth.Guardian.Pipeline do
use Guardian.Plug.Pipeline,
otp_app: :orbit,
error_handler: OrbitWeb.Auth.Guardian.ErrorHandler,
module: OrbitWeb.Auth.Guardian

plug(Guardian.Plug.VerifySession, claims: %{"typ" => "access"})
plug(Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"})
plug(Guardian.Plug.LoadResource, allow_blank: true)
end

defmodule OrbitWeb.Auth.Guardian.ErrorHandler do
@behaviour Guardian.Plug.ErrorHandler

@impl Guardian.Plug.ErrorHandler
def auth_error(conn, {_type, _reason}, _opts) do
conn
|> Plug.Conn.put_session(:login_target, conn.request_path)
# remove the invalid Guardian session so we don't get caught in a redirect loop
|> OrbitWeb.Auth.Auth.logout()
|> OrbitWeb.AuthController.redirect_needs_login()
end
end
83 changes: 83 additions & 0 deletions lib/orbit_web/auth/strategy/fake_oidcc.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
defmodule OrbitWeb.Auth.Strategy.FakeOidcc do
use Ueberauth.Strategy, ignores_csrf_attack: true

@impl Ueberauth.Strategy
def handle_request!(conn) do
conn
|> put_resp_content_type("text/html")
|> send_resp(
:ok,
~s(
<!doctype html>
<html>
<h1>Fake Keycloak/Oidcc</h1>
<form action="/auth/keycloak/callback">
<label>Email: <input type="email" name="email" value="[email protected]"></label>
<input type="submit" value="Log In">
</form>
</html>
)
)
|> halt()
end

@impl Ueberauth.Strategy
def handle_callback!(conn) do
# add a /.../callback?invalid query param to mock an invalid token for testing
if Map.has_key?(conn.params, "invalid") or is_nil(conn.params["email"]) do
set_errors!(conn, [error("invalid", "invalid callback")])
else
conn
end
end

@impl Ueberauth.Strategy
def uid(_conn) do
"fake_uid"
end

@impl Ueberauth.Strategy
def credentials(_conn) do
nine_hours_in_seconds = 9 * 60 * 60
expiration_time = System.system_time(:second) + nine_hours_in_seconds

%Ueberauth.Auth.Credentials{
token: "fake_access_token",
refresh_token: "fake_refresh_token",
expires: true,
expires_at: expiration_time
}
end

@impl Ueberauth.Strategy
def info(conn) do
email = Map.get(conn.params, "email")

%Ueberauth.Auth.Info{
email: email
}
end

@impl Ueberauth.Strategy
def extra(conn) do
groups = conn.params["groups"] || []

keycloak_client_id =
get_in(Application.get_env(:ueberauth_oidcc, :providers), [:keycloak, :client_id])

%Ueberauth.Auth.Extra{
raw_info: %UeberauthOidcc.RawInfo{
userinfo: %{
"resource_access" => %{
keycloak_client_id => %{"roles" => groups}
}
}
}
}
end

@impl Ueberauth.Strategy
def handle_cleanup!(conn) do
conn
end
end
Loading

0 comments on commit 873c6f8

Please sign in to comment.