diff --git a/lib/tempest/accounts/tokens.ex b/lib/tempest/accounts/tokens.ex index 4bef4f3..c3e8a54 100644 --- a/lib/tempest/accounts/tokens.ex +++ b/lib/tempest/accounts/tokens.ex @@ -47,7 +47,7 @@ defmodule Tempest.Accounts.Tokens do end def verify_access_token(token) when is_binary(token) do - case verify_session_jwt(token, "access", @access_max_age_seconds) do + case verify_session_jwt(token, :access, @access_max_age_seconds) do {:ok, claims} -> {:ok, claims} {:error, _reason} -> Phoenix.Token.verify(Endpoint, @access_salt, token, max_age: @access_max_age_seconds) end @@ -258,17 +258,17 @@ defmodule Tempest.Accounts.Tokens do end end - defp validate_session_jwt_header(%{"alg" => "ES256K", "typ" => typ}, "access") + defp validate_session_jwt_header(%{"alg" => "ES256K", "typ" => typ}, :access) when typ in ["at+jwt", "JWT"], do: :ok - defp validate_session_jwt_header(%{"alg" => "ES256K", "typ" => typ}, "refresh") + defp validate_session_jwt_header(%{"alg" => "ES256K", "typ" => typ}, :refresh) when typ in ["refresh+jwt", "JWT"], do: :ok defp validate_session_jwt_header(_header, _expected_typ), do: {:error, :invalid} - defp validate_session_claim_shape(%{"typ" => "access", "scope" => "com.atproto.access"} = claims, "access") do + defp validate_session_claim_shape(%{"typ" => "access", "scope" => "com.atproto.access"} = claims, :access) do with did when is_binary(did) and did != "" <- Map.get(claims, "sub"), ^did <- Map.get(claims, "iss"), account_id when is_integer(account_id) <- Map.get(claims, "account_id"), @@ -279,7 +279,7 @@ defmodule Tempest.Accounts.Tokens do end end - defp validate_session_claim_shape(%{"typ" => "refresh", "scope" => "com.atproto.refresh"} = claims, "refresh") do + defp validate_session_claim_shape(%{"typ" => "refresh", "scope" => "com.atproto.refresh"} = claims, :refresh) do with did when is_binary(did) and did != "" <- Map.get(claims, "sub"), ^did <- Map.get(claims, "iss") do :ok @@ -291,14 +291,15 @@ defmodule Tempest.Accounts.Tokens do defp validate_session_claim_shape(_claims, _expected_typ), do: {:error, :invalid} defp validate_session_claims( - %{"typ" => expected_typ, "sub" => did, "iss" => did, "aud" => aud, "iat" => iat, "exp" => exp}, + %{"typ" => typ, "sub" => did, "iss" => did, "aud" => aud, "iat" => iat, "exp" => exp}, expected_typ, max_age_seconds ) - when is_integer(iat) and is_integer(exp) do + when is_binary(typ) and is_integer(iat) and is_integer(exp) do now = DateTime.utc_now() |> DateTime.to_unix() cond do + typ != Atom.to_string(expected_typ) -> {:error, :invalid} aud != service_did() -> {:error, :invalid} iat > now + 60 -> {:error, :invalid} exp <= now -> {:error, :expired_token} diff --git a/lib/tempest/xrpc/proxy.ex b/lib/tempest/xrpc/proxy.ex index 8a3c61f..6b901ff 100644 --- a/lib/tempest/xrpc/proxy.ex +++ b/lib/tempest/xrpc/proxy.ex @@ -3,6 +3,9 @@ defmodule Tempest.Xrpc.Proxy do Fallback proxy policy for service XRPC methods intentionally not implemented locally. """ + alias Tempest.Accounts + alias Tempest.Accounts.Tokens + @service_prefixes ["app.bsky.", "chat.bsky."] def proxyable?(nsid) when is_binary(nsid) do @@ -34,7 +37,12 @@ defmodule Tempest.Xrpc.Proxy do request = options - |> Keyword.merge(method: conn.method, url: url(upstream, nsid), headers: forwarded_headers(conn), retry: false) + |> Keyword.merge( + method: conn.method, + url: url(upstream, nsid), + headers: forwarded_headers(conn, upstream, nsid), + retry: false + ) |> maybe_query(conn, params) |> maybe_json(conn, params) @@ -58,8 +66,56 @@ defmodule Tempest.Xrpc.Proxy do defp maybe_json(request, _conn, _params), do: request - defp forwarded_headers(conn) do + defp forwarded_headers(conn, upstream, nsid) do conn.req_headers - |> Enum.filter(fn {name, _value} -> name in ["authorization", "accept", "content-type"] end) + |> Enum.filter(fn {name, _value} -> + name in ["accept", "content-type", "atproto-accept-labelers", "x-atproto-accept-labelers"] + end) + |> maybe_put_service_auth(conn, upstream, nsid) + end + + defp maybe_put_service_auth(headers, conn, upstream, nsid) do + with {:ok, token} <- bearer_token(conn), + {:ok, auth_context} <- Accounts.authenticate_access(token), + {:ok, audience} <- service_audience(conn, upstream) do + [{"authorization", "Bearer #{Tokens.sign_service_auth(auth_context.account, audience, nsid)}"} | headers] + else + _other -> headers + end + end + + defp bearer_token(conn) do + conn.req_headers + |> Enum.find_value(fn + {"authorization", "Bearer " <> token} when token != "" -> {:ok, token} + {"authorization", "bearer " <> token} when token != "" -> {:ok, token} + _header -> nil + end) + |> case do + {:ok, token} -> {:ok, token} + nil -> :error + end end + + defp service_audience(conn, upstream) do + conn.req_headers + |> Enum.find_value(fn + {"atproto-proxy", value} when is_binary(value) -> value + _header -> nil + end) + |> case do + nil -> service_audience_from_url(upstream) + proxy -> proxy |> String.split("#", parts: 2) |> List.first() |> validate_did_audience() + end + end + + defp service_audience_from_url(upstream) do + case URI.parse(upstream) do + %{host: host} when is_binary(host) and host != "" -> {:ok, "did:web:#{host}"} + _uri -> {:error, :invalid_upstream} + end + end + + defp validate_did_audience("did:" <> _rest = did), do: {:ok, did} + defp validate_did_audience(_audience), do: {:error, :invalid_audience} end diff --git a/test/tempest_web/xrpc/proxy_fallback_test.exs b/test/tempest_web/xrpc/proxy_fallback_test.exs index c639475..e0fd4fa 100644 --- a/test/tempest_web/xrpc/proxy_fallback_test.exs +++ b/test/tempest_web/xrpc/proxy_fallback_test.exs @@ -1,6 +1,8 @@ defmodule TempestWeb.Xrpc.ProxyFallbackTest do use TempestWeb.ConnCase, async: false + alias Tempest.Accounts.Tokens + setup context do Req.Test.set_req_test_from_context(context) Req.Test.verify_on_exit!(context) @@ -29,16 +31,31 @@ defmodule TempestWeb.Xrpc.ProxyFallbackTest do assert json_response(proxy_conn, 200) == %{"feed" => []} end - test "unknown app.bsky procedures proxy JSON bodies and bearer headers to configured AppView", %{conn: conn} do + test "unknown app.bsky procedures proxy JSON bodies and service auth to configured AppView", %{conn: conn} do Application.put_env(:tempest, Tempest.Xrpc.Proxy, upstream_base_url: "https://appview.example", http_req_options: [plug: {Req.Test, __MODULE__}] ) + account = + conn + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.server.createAccount", %{ + "handle" => "proxy-auth.test", + "email" => "proxy-auth@example.com", + "password" => "correct horse battery staple" + }) + |> json_response(200) + Req.Test.expect(__MODULE__, fn req_conn -> assert req_conn.method == "POST" assert req_conn.request_path == "/xrpc/app.bsky.feed.sendInteractions" - assert Plug.Conn.get_req_header(req_conn, "authorization") == ["Bearer appview-token"] + assert ["Bearer " <> service_auth] = Plug.Conn.get_req_header(req_conn, "authorization") + assert {:ok, claims} = Tokens.verify_service_auth(service_auth) + assert claims["iss"] == account["did"] + assert claims["aud"] == "did:web:appview.example" + assert claims["lxm"] == "app.bsky.feed.sendInteractions" + {:ok, body, req_conn} = Plug.Conn.read_body(req_conn) assert Jason.decode!(body) == %{"interactions" => []} @@ -52,7 +69,8 @@ defmodule TempestWeb.Xrpc.ProxyFallbackTest do proxy_conn = conn - |> put_req_header("authorization", "Bearer appview-token") + |> recycle() + |> put_req_header("authorization", "Bearer #{account["accessJwt"]}") |> put_req_header("content-type", "application/json") |> post(~p"/xrpc/app.bsky.feed.sendInteractions", %{"interactions" => []})