Files
policy-service/lib/policy_service_web/plugs/authentication_plug.ex
HaimKortovich 2137cf4959
All checks were successful
Build and Publish / build-release (push) Successful in 1m30s
make provider config simpler
2026-05-04 16:06:19 -05:00

188 lines
5.3 KiB
Elixir

defmodule PolicyServiceWeb.Plugs.AuthenticationPlug do
@moduledoc """
Authentication plug for validating JWT tokens using Zitadel.
This plug validates JWT tokens using the oidcc library and extracts
user claims for use throughout the application.
"""
import Plug.Conn
require Logger
@doc """
Initializes the authentication plug.
## Options
- :provider - The OIDCC provider configuration worker name (required)
"""
def init(opts) do
provider = Keyword.fetch!(opts, :provider)
zitadel_config = Application.get_env(:policy_service, :zitadel, [])
%{
provider: provider,
client_id: Keyword.get(zitadel_config, :client_id),
client_secret: Keyword.get(zitadel_config, :client_secret),
required_scopes: Keyword.get(zitadel_config, :required_scopes, [])
}
end
@doc """
Authenticates the request by validating the JWT token.
Extracts the Authorization header, validates the JWT token using
Zitadel's JWKS, and assigns the extracted claims to the connection.
"""
def call(conn, config) do
with {:ok, token} <- extract_token(conn),
{:ok, validated_token} <- validate_token(token, config),
{:ok, claims} <- extract_claims(validated_token) do
conn
|> assign(:authenticated, true)
|> assign(:current_user, claims)
|> assign(:user_id, claims.user_id)
|> assign(:org_id, claims.org_id)
|> assign(:roles, claims.roles)
|> assign(:scopes, claims.scopes)
else
{:error, :missing_token} ->
handle_missing_token(conn)
{:error, :invalid_token} = error ->
handle_invalid_token(conn, error)
{:error, :expired_token} = error ->
handle_expired_token(conn, error)
{:error, :insufficient_scopes} = error ->
handle_insufficient_scopes(conn, error)
{:error, reason} = error ->
handle_authentication_error(conn, error, reason)
end
end
defp extract_token(conn) do
case get_req_header(conn, "authorization") do
["Bearer " <> token] when token != "" ->
{:ok, token}
["Bearer " <> token] when token == "" ->
{:error, :missing_token}
["bearer " <> token] when token != "" ->
{:ok, token}
[] ->
{:error, :missing_token}
_ ->
{:error, :invalid_token}
end
end
defp validate_token(token, config) do
try do
case Oidcc.Token.validate_jwt(
token,
config.provider,
%{}
) do
{:ok, validated_token} ->
validate_required_scopes(validated_token, config)
{:error, :invalid_token} ->
Logger.warning("Invalid JWT token provided")
{:error, :invalid_token}
{:error, :expired_token} ->
Logger.warning("Expired JWT token provided")
{:error, :expired_token}
{:error, reason} ->
Logger.error("Token validation failed: #{inspect(reason)}")
{:error, :invalid_token}
end
rescue
error ->
Logger.error("Token validation error: #{inspect(error)}")
{:error, :invalid_token}
end
end
defp validate_required_scopes(validated_token, config) do
required_scopes = config.required_scopes || []
if required_scopes == [] do
{:ok, validated_token}
else
token_scopes = PolicyServiceWeb.Plugs.ClaimsExtractor.get_scopes(validated_token.claims)
if has_all_required_scopes?(token_scopes, required_scopes) do
{:ok, validated_token}
else
missing_scopes = required_scopes -- token_scopes
Logger.warning("Token missing required scopes: #{inspect(missing_scopes)}")
{:error, :insufficient_scopes}
end
end
end
defp has_all_required_scopes?(token_scopes, required_scopes) do
Enum.all?(required_scopes, fn scope -> scope in token_scopes end)
end
defp extract_claims(validated_token) do
try do
claims = PolicyServiceWeb.Plugs.ClaimsExtractor.extract_claims(validated_token)
PolicyServiceWeb.Plugs.ClaimsExtractor.validate_claims!(validated_token.claims)
{:ok, claims}
rescue
error ->
Logger.error("Failed to extract claims: #{inspect(error)}")
{:error, :invalid_claims}
end
end
defp handle_missing_token(conn) do
conn
|> put_resp_content_type("application/json")
|> send_resp(401, Jason.encode!(%{error: "Missing authentication token"}))
|> halt()
end
defp handle_invalid_token(conn, _error) do
conn
|> put_resp_content_type("application/json")
|> send_resp(401, Jason.encode!(%{error: "Invalid authentication token"}))
|> halt()
end
defp handle_expired_token(conn, _error) do
conn
|> put_resp_content_type("application/json")
|> send_resp(401, Jason.encode!(%{error: "Authentication token has expired"}))
|> halt()
end
defp handle_insufficient_scopes(conn, _error) do
conn
|> put_resp_content_type("application/json")
|> send_resp(
403,
Jason.encode!(%{error: "Insufficient permissions - required scopes not granted"})
)
|> halt()
end
defp handle_authentication_error(conn, _error, reason) do
Logger.error("Authentication error: #{inspect(reason)}")
conn
|> put_resp_content_type("application/json")
|> send_resp(401, Jason.encode!(%{error: "Authentication failed"}))
|> halt()
end
end