Packages
attesto_phoenix
0.6.3
2.1.0
2.0.2
2.0.1
2.0.0
1.4.0
1.3.0
1.2.0
1.1.0
1.0.0
0.20.0
0.19.1
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.2
0.14.1
0.14.0
0.13.5
0.13.4
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.0
0.10.0
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.0
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
Phoenix/Ecto OAuth 2.0 / OIDC authorization server layer over attesto: authorization, token, PAR, revocation, discovery, JWKS, UserInfo, protected-resource plugs, and Ecto-backed token stores.
Current section
Files
Jump to
Current section
Files
lib/attesto_phoenix/controller/par_controller.ex
defmodule AttestoPhoenix.Controller.PARController do
@moduledoc """
Pushed Authorization Request endpoint (RFC 9126).
The endpoint authenticates the client, stores the submitted authorization
request parameters behind a `request_uri`, and returns that reference to be
used at `/oauth/authorize`. The authorization endpoint still performs the
normal client/redirect/scope/PKCE validation when the reference is resolved.
"""
use Phoenix.Controller, formats: [:json]
import Plug.Conn
alias Attesto.ClientAssertion
alias AttestoPhoenix.{Config, RequestContext}
@cache_control_no_store "no-store"
@pragma_no_cache "no-cache"
@error_invalid_client "invalid_client"
@error_invalid_request "invalid_request"
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, params) do
config = resolve_config()
conn = put_no_store(conn)
with :ok <- RequestContext.check_https(conn, config),
{:ok, client} <- authenticate_client(config, conn, params),
{:ok, stored} <- store_request(config, client, params) do
conn
|> put_status(:created)
|> json(stored)
else
{:error, :insecure_transport} ->
render_error(conn, @error_invalid_request, "TLS required")
{:error, {code, desc}} ->
render_error(conn, code, desc)
end
end
defp resolve_config do
otp_app = Application.get_env(:attesto_phoenix, :otp_app)
Config.from_otp_app(otp_app, Config)
end
defp authenticate_client(config, conn, params) do
with {:ok, method} <- client_auth_method(conn, params) do
verify_client_auth(config, method)
end
end
defp client_auth_method(
conn,
%{"client_assertion_type" => type, "client_assertion" => assertion} = params
)
when is_binary(assertion) and assertion != "" do
if get_req_header(conn, "authorization") == [] and not is_map_key(params, "client_secret") do
if type == ClientAssertion.assertion_type() do
{:ok, {:private_key_jwt, assertion}}
else
{:error, {@error_invalid_client, "client authentication failed"}}
end
else
{:error, {@error_invalid_request, "multiple client authentication methods"}}
end
end
defp client_auth_method(conn, params) do
case get_req_header(conn, "authorization") do
["Basic " <> encoded] when not is_map_key(params, "client_secret") ->
with {:ok, client_id, secret} <- decode_basic(encoded),
true <- not is_map_key(params, "client_assertion") do
{:ok, {:secret, client_id, secret}}
else
false -> {:error, {@error_invalid_request, "multiple client authentication methods"}}
{:error, _} = error -> error
end
["Basic " <> _] ->
{:error, {@error_invalid_request, "multiple client authentication methods"}}
[] ->
with client_id when is_binary(client_id) and client_id != "" <- params["client_id"],
secret when is_binary(secret) and secret != "" <- params["client_secret"] do
{:ok, {:secret, client_id, secret}}
else
_ -> {:error, {@error_invalid_client, "client authentication failed"}}
end
_ ->
{:error, {@error_invalid_client, "unsupported client authentication scheme"}}
end
end
defp verify_client_auth(config, {:secret, client_id, secret}),
do: load_and_verify(config, client_id, secret)
defp verify_client_auth(config, {:private_key_jwt, assertion}) do
with {:ok, client_id} <- ClientAssertion.peek_client_id(assertion),
{:ok, client} <- load_existing_client(config, client_id),
{:ok, jwks} <- client_jwks(config, client),
{:ok, _claims} <-
ClientAssertion.verify(assertion, client_id, client_assertion_audiences(config), jwks,
max_lifetime: 300
) do
{:ok, client}
else
_ -> {:error, {@error_invalid_client, "client authentication failed"}}
end
end
defp decode_basic(encoded) do
with {:ok, decoded} <- Base.decode64(encoded),
[client_id, secret] <- String.split(decoded, ":", parts: 2) do
{:ok, URI.decode_www_form(client_id), URI.decode_www_form(secret)}
else
_ -> {:error, {@error_invalid_client, "client authentication failed"}}
end
end
defp load_and_verify(config, client_id, secret) do
case invoke(config.load_client, [client_id]) do
{:ok, client} ->
if invoke(config.verify_client_secret, [client, secret]) == true,
do: {:ok, client},
else: {:error, {@error_invalid_client, "client authentication failed"}}
_ ->
_ = invoke(config.verify_client_secret, [:unknown_client, secret])
{:error, {@error_invalid_client, "client authentication failed"}}
end
end
defp load_existing_client(config, client_id) do
case invoke(config.load_client, [client_id]) do
{:ok, client} -> {:ok, client}
_ -> {:error, :invalid_client}
end
end
defp client_jwks(config, client) do
case config_callback(config, :client_jwks) do
nil ->
{:error, :missing_client_jwks}
callback ->
case invoke(callback, [client]) do
{:ok, jwks} -> {:ok, jwks}
jwks when is_map(jwks) or is_list(jwks) -> {:ok, jwks}
_ -> {:error, :missing_client_jwks}
end
end
end
defp client_assertion_audiences(config) do
issuer = String.trim_trailing(config.issuer, "/")
[config.issuer, issuer <> "/oauth/par", issuer <> "/oauth/token"]
end
defp store_request(config, client, params) do
ttl = config_field(config, :par_ttl, 90)
request_uri = "urn:ietf:params:oauth:request_uri:" <> random()
stored =
params
|> Map.drop(["client_secret", "client_assertion", "client_assertion_type"])
|> Map.put_new("client_id", client_id(config, client))
case par_store(config).put(request_uri, stored, ttl) do
:ok -> {:ok, %{request_uri: request_uri, expires_in: ttl}}
_ -> {:error, {@error_invalid_request, "could not store pushed authorization request"}}
end
end
defp client_id(config, client) do
case config_callback(config, :client_id) do
nil -> Map.get(client, :id) || Map.get(client, "id")
callback -> invoke(callback, [client])
end
end
defp par_store(config), do: config_field(config, :par_store, AttestoPhoenix.Store.PAR.ETS)
defp random, do: 32 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)
defp put_no_store(conn) do
conn
|> put_resp_header("cache-control", @cache_control_no_store)
|> put_resp_header("pragma", @pragma_no_cache)
end
defp render_error(conn, code, desc) do
conn
|> put_status(:bad_request)
|> json(%{error: code, error_description: desc})
end
defp config_field(config, field, default), do: Map.get(config, field, default)
defp config_callback(config, field) do
case Map.fetch(config, field) do
{:ok, fun} -> fun
:error -> nil
end
end
defp invoke(fun, args) when is_function(fun), do: apply(fun, args)
defp invoke({mod, fun}, args), do: apply(mod, fun, args)
defp invoke({mod, fun, extra}, args) when is_list(extra), do: apply(mod, fun, args ++ extra)
end