AttestoPhoenix Live Demo
This notebook starts a tiny Phoenix router under Bandit, wires
AttestoPhoenix.Plug.Authenticate, mints an Attesto access token, and calls the
protected route with Req plus
ReqDPoP. It is a self-contained demo of
the resource-server side of attesto_phoenix.
For a production authorization server, use the installer and migrations from the README:
mix attesto_phoenix.install
mix attesto_phoenix.gen.migration --repo MyApp.Repo
mix ecto.migrate
Install packages
Mix.install([
{:attesto_phoenix, "~> 3.0"},
{:req, "~> 0.5"},
{:req_dpop, "~> 0.5"},
{:bandit, "~> 1.0"},
{:phoenix, "~> 1.7"}
])
Configure a demo issuer
defmodule DemoKeystore do
@behaviour Attesto.Keystore
@impl true
def signing_pem do
:persistent_term.get({__MODULE__, :signing_pem})
end
@impl true
def verification_pems, do: [signing_pem()]
end
signing_pem =
JOSE.JWK.generate_key({:rsa, 2048})
|> JOSE.JWK.to_pem()
|> elem(1)
:persistent_term.put({DemoKeystore, :signing_pem}, signing_pem)
principal_kind =
Attesto.PrincipalKind.new("user", "usr_",
required_claims: [{"client_id", :non_empty_string}]
)
demo_port = 4057 + :rand.uniform(1000)
demo_base_url = "https://localhost:#{demo_port}"
attesto_phoenix_config =
AttestoPhoenix.Config.new(
issuer: "https://issuer.example",
audience: "https://issuer.example",
keystore: DemoKeystore,
repo: DemoRepo,
load_client: fn _client_id -> {:error, :not_found} end,
verify_client_secret: fn _client, _secret -> false end,
load_principal: fn subject -> {:ok, %{subject: subject, role: :demo}} end,
principal_kinds: [principal_kind],
# Demo-only: production deployments should use a shared replay store. The
# first argument is an opaque replay identity (the DPoP proof's jti
# namespaced by its key), not the raw jti — treat it as an opaque key.
replay_check: fn _replay_key, _ttl_seconds -> :ok end,
# Demo-only: pin the canonical DPoP htu to the HTTPS route the notebook
# calls. Attesto rejects DPoP proofs over plain HTTP.
htu: fn _conn -> demo_base_url <> "/api/protected" end,
require_https: false
)
attesto_config =
AttestoPhoenix.Config.to_attesto_config(attesto_phoenix_config,
principal_kinds: [principal_kind]
)
Start a Phoenix router
defmodule DemoProtectedPlug do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
body =
JSON.encode!(%{
ok: true,
subject: conn.assigns.attesto_context.subject,
scopes: conn.assigns.attesto_context.scope,
sender: conn.assigns.attesto_context.cnf
})
conn
|> put_resp_content_type("application/json")
|> send_resp(200, body)
end
end
defmodule DemoRouter do
use Phoenix.Router
pipeline :protected do
plug :authenticate
end
scope "/api" do
pipe_through :protected
forward "/protected", DemoProtectedPlug
end
defp authenticate(conn, _opts) do
AttestoPhoenix.Plug.Authenticate.call(conn,
config: :persistent_term.get({__MODULE__, :attesto_phoenix_config})
)
end
end
:persistent_term.put({DemoRouter, :attesto_phoenix_config}, attesto_phoenix_config)
today = Date.utc_today()
validity = {Date.add(today, -1) |> Date.to_erl(), Date.add(today, 3_650) |> Date.to_erl()}
demo_tls =
:public_key.pkix_test_root_cert(~c"localhost",
key: {:rsa, 2048, 65_537},
digest: :sha256,
validity: validity
)
demo_cert = :public_key.pem_encode([{:Certificate, demo_tls.cert, :not_encrypted}])
demo_key =
:public_key.pem_encode([
:public_key.pem_entry_encode(:RSAPrivateKey, demo_tls.key)
])
demo_tls_dir = Path.join(System.tmp_dir!(), "attesto_phoenix_demo_#{System.unique_integer([:positive])}")
:ok = File.mkdir_p(demo_tls_dir)
File.chmod!(demo_tls_dir, 0o700)
certfile = Path.join(demo_tls_dir, "cert.pem")
keyfile = Path.join(demo_tls_dir, "key.pem")
File.write!(certfile, demo_cert)
File.write!(keyfile, demo_key)
File.chmod!(keyfile, 0o600)
{:ok, _server} =
Bandit.start_link(
plug: DemoRouter,
scheme: :https,
port: demo_port,
certfile: certfile,
keyfile: keyfile
)
Mint a DPoP-bound token
dpop_key = ReqDPoP.Key.generate(:es256)
dpop_jkt = ReqDPoP.Key.thumbprint(dpop_key)
principal = %{
kind: "user",
sub: "usr_demo_123",
scopes: ["openid", "read:demo"],
claims: %{"client_id" => "demo-client"}
}
{:ok, minted} = Attesto.Token.mint(attesto_config, principal, dpop_jkt: dpop_jkt)
minted.token_type
Call the protected route with Req + ReqDPoP
client =
Req.new(
base_url: demo_base_url,
connect_options: [transport_opts: [verify: :verify_none]]
)
|> ReqDPoP.attach(key: dpop_key, access_token: minted.access_token)
response = Req.get!(client, url: "/api/protected")
{response.status, response.body}
Expected result:
{200,
%{
"ok" => true,
"scopes" => ["openid", "read:demo"],
"sender" => %{"jkt" => ^dpop_jkt},
"subject" => "usr_demo_123"
}}
Present the same token as Bearer
A DPoP-bound token must not work as a plain Bearer token.
bearer_response =
Req.get!(demo_base_url <> "/api/protected",
headers: [{"authorization", "Bearer " <> minted.access_token}],
connect_options: [transport_opts: [verify: :verify_none]]
)
{bearer_response.status, bearer_response.body}
Expected result:
{401, %{"error" => "invalid_token"}}