Powered by AppSignal & Oban Pro

AttestoPhoenix Live Demo

notebooks/attesto_phoenix_demo.livemd

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"}}