Skip to content

Instantly share code, notes, and snippets.

@maennchen
Created April 27, 2021 15:16
Show Gist options
  • Save maennchen/544646709e53bb976f623793e0c26c0b to your computer and use it in GitHub Desktop.
Save maennchen/544646709e53bb976f623793e0c26c0b to your computer and use it in GitHub Desktop.
Zitadel Elixir
config :oidcc, http_request_timeout: 60
config :acme, :providers,
zitadel: [
issuer_or_config_endpoint: System.get_env("IAM_ISSUER", "https://issuer.zitadel.ch"),
client_id: System.fetch_env!("WEB_IAM_CLIENT_ID"),
client_secret: System.fetch_env!("WEB_IAM_CLIENT_SECRET"),
local_endpoint: "https://example.com/auth/oidc/callback",
request_scopes: ["openid", "profile", "email"]
]
config :acme, :service_accounts,
service_account_name: [
login: System.fetch_env!("IAM_SERVICE_ACCOUNT_USER_SYNC_LOGIN")
]
defmodule Acme.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl Application
def start(_type, _args) do
:application.set_env(:oidcc, :cacertfile, :certifi.cacertfile())
for {provider, config} <- Application.fetch_env!(:acme, :providers) do
:oidcc.add_openid_provider(
Keyword.fetch!(config, :issuer_or_config_endpoint),
Keyword.fetch!(config, :local_endpoint),
config |> Map.new() |> Map.put_new(:id, Atom.to_string(provider))
)
end
for {provider, _config} <- Application.fetch_env!(:acme, :providers) do
:ok = wait_for_config_retrieval(provider)
end
Supervisor.start_link([], strategy: :one_for_one, name: Acme.Supervisor)
end
defp wait_for_config_retrieval(provider) do
provider
|> Atom.to_string()
|> :oidcc.get_openid_provider_info()
|> case do
{:ok, %{ready: false}} ->
:undefined = get_error(provider)
Process.sleep(100)
wait_for_config_retrieval(provider)
{:ok, %{ready: true}} ->
:ok
end
end
defp get_error(provider) do
{:ok, pid} =
provider
|> Atom.to_string()
|> :oidcc_openid_provider_mgr.get_openid_provider()
{:ok, error} = :oidcc_openid_provider.get_error(pid)
error
end
end
defmodule Acme.IAM do
@moduledoc """
IAM
"""
defmodule OidcError do
defexception [:message]
end
@spec organisation_id :: String.t()
def organisation_id, do: Application.fetch_env!(:acme, :organisation_id)
@spec project_id :: String.t()
def project_id, do: Application.fetch_env!(:acme, :project_id)
@spec service_login(provider :: String.t(), service_user_name :: atom) ::
{:ok, String.t(), pos_integer()} | {:error, term}
def service_login(provider, service_user_name) do
with {:ok, config} <- Application.fetch_env(:acme, :service_accounts),
{:ok, config} <- Keyword.fetch(config, service_user_name),
config = Map.new(config),
{:ok, %{token_endpoint: token_endpoint} = oidc_config} <-
:oidcc.get_openid_provider_info(provider),
{:ok, assertion} <- client_credential_jwt(config, oidc_config),
{:ok, %{body: body}} <-
:oidcc_http_util.sync_http(
:post,
token_endpoint,
[],
"application/x-www-form-urlencoded",
"assertion=#{:http_uri.encode(assertion)}&grant_type=#{
:http_uri.encode("urn:ietf:params:oauth:grant-type:jwt-bearer")
}&scope=#{:http_uri.encode("urn:zitadel:iam:org:project:id:69234237810729019:aud")}"
),
{:ok, %{"access_token" => access_token, "expires_in" => expires_in}} <-
Jason.decode(body) do
{:ok, access_token, expires_in}
else
{:error, reason} ->
{:error, reason}
:error ->
{:error, :missing_application_config}
{:ok,
%{
"error" => "server_error",
"error_description" => "issuedAt of token is in the future" <> _
}} ->
{:error, :iat_in_future}
{:ok, %{"error" => type, "error_description" => description}} ->
{:error, {type, description}}
end
end
defp client_credential_jwt(%{login: login} = _config, oidc_config) do
with {:ok, %{"key" => key, "keyId" => key_id} = login} <- Jason.decode(login),
jwk = JOSE.JWK.from_pem(key),
{:ok, claims} <- client_credential_claims(login, oidc_config),
header = %{
"alg" => "RS256",
"typ" => "JWT"
},
{_, assertion} <-
jwk
|> JOSE.JWS.sign(claims, header, %{"alg" => "RS256", "kid" => key_id})
|> JOSE.JWS.compact() do
{:ok, assertion}
else
{:error, reason} -> {:error, reason}
{:ok, %{} = other} -> {:error, {:invalid_key_or_claims, other}}
end
end
defp client_credential_jwt(_config, _oidc_config), do: {:error, :missing_credential_config}
defp client_credential_claims(
%{"userId" => user_id} = _login,
%{issuer: issuer} = _oidc_config
) do
iat = :os.system_time(:seconds)
exp = iat + 60
Jason.encode(%{
"iss" => user_id,
"sub" => user_id,
"aud" => [issuer],
"exp" => exp,
"iat" => iat,
"nbf" => iat
})
end
defp client_credential_claims(_login, _oidc_config), do: {:error, :missing_claims_config}
@opaque session(state) :: %{
id: String.t(),
provider: String.t(),
scopes: [String.t()],
pkce: %{
verifier: String.t(),
challenge: String.t(),
method: :plain | :S256
},
state: state,
expiry: NaiveDateTime.t(),
nonce: String.t()
}
@spec generate_session_info(provider :: String.t(), state :: state) :: session(state)
when state: term
def generate_session_info(provider, state \\ nil) do
{:ok, %{request_scopes: request_scopes} = config} = :oidcc.get_openid_provider_info(provider)
%{
id: random_string(),
provider: provider,
scopes:
case request_scopes do
:undefined -> Application.get_env(:oidcc, :scopes, [:openid])
list when is_list(list) -> list
end,
pkce:
case config do
%{code_challenge_methods_supported: methods} -> generate_pkce(methods)
%{} -> :undefined
end,
state: state,
expiry: NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(5), :millisecond),
nonce: random_string(64)
}
end
@spec generate_redirect_url!(session :: session(term())) :: String.t()
def generate_redirect_url!(%{
provider: provider,
scopes: scopes,
id: id,
nonce: nonce,
pkce: pkce
}) do
provider
|> :oidcc.create_redirect_url(%{scopes: scopes, state: id, nonce: nonce, pkce: pkce})
|> case do
{:ok, url} -> url
{:error, :provider_not_ready} -> raise OidcError, "provider not ready"
end
end
@spec clean_sessions(sessions :: [session(state)]) :: [session(state)] when state: term
def clean_sessions(sessions) do
sessions
|> Enum.filter(&(NaiveDateTime.compare(&1.expiry, NaiveDateTime.utc_now()) == :gt))
|> Enum.take(2)
end
@spec retrieve_and_validate_token!(sessions :: [session(state)], params :: map) ::
%{
id: map(),
access: map(),
provider: String.t(),
state: state,
remaining_sessions: [session(state)]
}
when state: term
def retrieve_and_validate_token!(sessions, params) do
{state, code} = gather_callback_params!(params)
%{provider: provider, pkce: pkce, nonce: nonce, scopes: scopes, state: state} =
session = find_session(sessions, state)
remaining_sessions = Enum.reject(sessions, &(&1 == session))
tokens =
code
|> :oidcc.retrieve_and_validate_token(provider, %{nonce: nonce, pkce: pkce, scope: scopes})
|> case do
{:ok, tokens} ->
tokens
{:error, reason} when is_atom(reason) or is_binary(reason) ->
raise OidcError, "oidc_error: #{inspect(reason)}"
{:error, reason} ->
raise OidcError, "oidc_error: #{inspect(reason, pretty: true)}"
end
Map.merge(tokens, %{
state: state,
remaining_sessions: remaining_sessions,
provider: provider
})
end
defp gather_callback_params!(%{"error" => error}) do
raise OidcError, "oidc_provider_error: #{inspect(error)}"
end
defp gather_callback_params!(params) do
state =
case params["state"] do
nil -> raise OidcError, "Query string does not contain field 'state'"
other -> other
end
code =
case params["code"] do
nil -> raise OidcError, "Query string does not contain field 'code'"
other -> other
end
{state, code}
end
defp find_session(sessions, state) do
session =
%{expiry: expiry} =
sessions
|> Enum.find(&(&1.id == state))
|> case do
nil -> raise OidcError, "session not found"
%{} = session -> session
end
case NaiveDateTime.compare(expiry, NaiveDateTime.utc_now()) do
:gt -> :ok
:eq -> :ok
:lt -> raise OidcError, "session expired"
end
session
end
defp generate_pkce(methods) do
pkce_key = random_string()
if Enum.member?(methods, "S256") do
%{
verifier: pkce_key,
challenge: :sha256 |> :crypto.hash(pkce_key) |> Base.encode64(),
method: :S256
}
else
%{
verifier: pkce_key,
challenge: pkce_key,
method: :plain
}
end
end
defp random_string(length \\ 32),
do: length |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)
end
defmodule AcmeWeb.AuthController do
use AcmeWeb, :controller
alias Acme.IAM
require Logger
@spec delete(conn :: Plug.Conn.t(), params :: %{String.t() => String.t()}) :: Plug.Conn.t()
def delete(conn, %{"provider" => provider}) do
conn =
conn
|> put_flash(:info, gettext("You have been logged out!"))
|> configure_session(drop: true)
case get_session(conn, :auth_tokens) do
nil ->
redirect(conn, to: "/")
_tokens ->
{:ok, %{end_session_endpoint: end_session_endpoint}} =
:oidcc.get_openid_provider_info(provider)
%{query: query} = end_session_uri = URI.parse(end_session_endpoint)
query =
query
|> Kernel.||("")
|> URI.decode_query()
|> Map.put("post_logout_redirect_uri", Routes.home_index_url(conn, :index))
|> URI.encode_query()
after_logout_url = URI.to_string(%{end_session_uri | query: query})
redirect(conn, external: after_logout_url)
end
end
@spec request(conn :: Plug.Conn.t(), params :: %{String.t() => String.t()}) :: Plug.Conn.t()
def request(conn, %{"provider" => provider} = params) do
session = IAM.generate_session_info(provider, params["return_url"])
redirect_uri = IAM.generate_redirect_url!(session)
conn
|> put_session(
__MODULE__,
IAM.clean_sessions([session | get_session(conn, __MODULE__) || []])
)
|> redirect(external: redirect_uri)
end
@spec callback(conn :: Plug.Conn.t(), params :: %{String.t() => String.t()}) :: Plug.Conn.t()
def callback(conn, params) do
tokens =
%{state: state, provider: provider, remaining_sessions: remaining_sessions} =
conn
|> get_session(__MODULE__)
|> case do
nil -> []
list when is_list(list) -> list
end
|> IAM.retrieve_and_validate_token!(params)
tokens
|> :oidcc.retrieve_user_info(provider)
|> case do
{:ok, user_info} ->
conn
|> put_flash(:info, gettext("Successfully authenticated."))
|> put_session(:auth, user_info)
|> put_session(:auth_tokens, {tokens, provider})
|> put_session(__MODULE__, remaining_sessions)
|> configure_session(renew: true)
|> redirect(
to:
case state do
nil -> "/"
other -> other
end
)
{:error, reason} ->
Logger.warn("""
Login failed, reason:
#{inspect(reason, pretty: true)}
""")
conn
|> put_status(:unauthorized)
|> render("oidc_error.html", reason: reason)
end
rescue
e in Acme.IAM.OidcError ->
Logger.warn("""
Login failed, reason:
#{inspect(e, pretty: true)}
""")
conn
|> put_status(:unauthorized)
|> render("oidc_error.html", reason: e)
end
end
defmodule AcmeWeb.Router do
use AcmeWeb, :router
# ...
scope "/", AcmeWeb do
pipe_through [:browser]
# ...
get "/auth/:provider", AuthController, :request
get "/auth/:provider/callback", AuthController, :callback
post "/auth/:provider/callback", AuthController, :callback
delete "/auth/", AuthController, :delete
# This route also exists as get because of this issue
# https://github.com/w3c/webappsec-csp/issues/8
get "/auth/", AuthController, :delete
end
end
defmodule Acme.MixProject do
# ...
def deps do
[
{:certifi, "~> 2.5"}, # CA Certificates
{:jason, "~> 1.0"}, # JSON Lib
{:oidcc, "~> 1.8"}, # OIDC Library
# ...
]
end
end

IAM Setup Zitadel

Phoenix (Code Flow)

  • Add deps to (mix.exs)
  • Add config (config/config.exs)
  • Add routes to (lib/acme_web/router.ex)
  • Start IAM Config (lib/acme/application.ex)
    • Set WEB_IAM_CLIENT_ID & WEB_IAM_CLIENT_SECRET env variables
  • Add IAM utilities (lib/acme/iam.ex)
  • Add Auth Controller (lib/acme_web/controllers/auth_controller.ex)
  • Redirect user to Routes.auth_path(conn, :request, "zitadel", return_url: return_url) for login
  • User Info / Tokens is now in session
  • Redirect user to Routes.auth_path(conn, :delete) for logout

Service Login

  • Set IAM_SERVICE_ACCOUNT_USER_SYNC_LOGIN to Key JSON
  • Call Acme.IAM.service_login("zitadel", :service_account_name) to get an access token
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment