From e7d9bbc53522c452bf9ad35b2e66f1a6c6dd0622 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Mon, 15 Jun 2026 00:08:27 -0500 Subject: [PATCH] feat: validate public OAuth client metadata --- CHANGELOG.md | 4 + TODO.md | 27 ++++ docs/reference/oauth.md | 52 +++---- lib/tempest/oauth.ex | 17 ++- lib/tempest/oauth/client_metadata.ex | 136 ++++++++++++++++++ .../controllers/oauth_controller.ex | 3 + .../tempest/interop/local_server_sdk_test.exs | 37 ++++- test/tempest/oauth/client_metadata_test.exs | 119 +++++++++++++++ .../controllers/oauth_flow_test.exs | 60 +++++++- .../xrpc/actor_preferences_test.exs | 39 ++++- .../xrpc/compatibility_auth_content_test.exs | 34 ++++- test/tempest_web/xrpc/plc_identity_test.exs | 28 +++- 12 files changed, 517 insertions(+), 39 deletions(-) create mode 100644 lib/tempest/oauth/client_metadata.ex create mode 100644 test/tempest/oauth/client_metadata_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 539472d..dc5ca26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## v0.1.0 +### 2026-06-15 + +- Added OAuth client metadata validation for the public-client path + ### 2026-06-13 - Added deployment verification support for post-deployment checks, including release diff --git a/TODO.md b/TODO.md index 172f887..075dc7a 100644 --- a/TODO.md +++ b/TODO.md @@ -1 +1,28 @@ # Parking Lot (TODO) + +- Add OAuth `private_key_jwt` client authentication: + - accept client metadata with `token_endpoint_auth_method: "private_key_jwt"` + - fetch and validate inline `jwks` or remote `jwks_uri` + - verify client assertions for `iss`, `sub`, `aud`, `exp`/`iat`, `jti`, `kid`, + algorithm, and signature + - reject assertion replay + - cover PAR, token exchange, refresh, and failure cases + +- Add OAuth loopback development client metadata: + - support the `http://localhost` client ID profile used by AT Protocol OAuth + development clients + - synthesize metadata from `redirect_uri` and `scope` query parameters + - restrict loopback redirect URIs to HTTP loopback hosts + - keep non-loopback clients on HTTPS metadata documents + +- Add OAuth private-use redirect scheme support for native clients: + - accept reverse-domain private-use schemes only for native clients + - reject credentials, hosts, ports, fragments, and local/reserved scheme roots + - keep HTTP redirect URI support limited to loopback clients + +- Add OAuth token introspection: + - expose `/oauth/introspect` + - return inactive for missing, revoked, rotated, expired, or malformed tokens + - return active token metadata for valid OAuth access or refresh tokens + - require client authentication and verify the requester is allowed to inspect + the token diff --git a/docs/reference/oauth.md b/docs/reference/oauth.md index 6046b5d..4da9cb4 100644 --- a/docs/reference/oauth.md +++ b/docs/reference/oauth.md @@ -34,8 +34,10 @@ the client retries with that nonce. ### Client Registration -Cocoon, Tranquil, and ZDS fetch and validate the `client_id` document. Tempest -does not. That is the largest behavioral difference. +Cocoon, Tranquil, ZDS, and Tempest fetch and validate the `client_id` document. +The difference is coverage. Tempest validates the public-client path; the other +implementations also cover more redirect forms, scope families, and private-key +client authentication. Cocoon validates the broadest metadata shape. It rejects unsupported metadata fields, unsafe redirect URI forms, local hostnames for ordinary web clients, @@ -50,14 +52,15 @@ loopback development clients. Non-local clients must use HTTPS. ZDS fetches client metadata during PAR. It checks the submitted redirect URI against `redirect_uris` and checks requested scopes against the scope list in -client metadata. Tempest records the submitted `client_id` and `redirect_uri`, -but it does not fetch the metadata document or verify redirect URI registration. +client metadata. Tempest now does the same for HTTPS public clients using +`token_endpoint_auth_method: "none"` and `dpop_bound_access_tokens: true`. ### Client Authentication Cocoon, Tranquil, and ZDS support public clients with `token_endpoint_auth_method: "none"` and private-key clients with -`private_key_jwt`. Tempest only advertises and implements public clients. +`private_key_jwt`. Tempest validates public clients and rejects private-key +clients for now. ZDS spells out the private-key JWT checks in a small code path. The assertion's issuer and subject must equal the client ID. The audience must equal the server @@ -95,8 +98,8 @@ against client metadata, and reject requests that mix transition scopes with granular scopes. Tempest uses a smaller allow-list: `atproto`, transition scopes, `blob:*/*`, -`rpc:*`, and `rpc:` forms. It does not compare requested scopes to client -metadata because it does not fetch client metadata. +`rpc:*`, and `rpc:` forms. It now also compares requested scopes to the +client metadata scope registration when the metadata declares one. ### Authorization UI @@ -233,31 +236,28 @@ are concrete: Tempest requires `client_id`, `redirect_uri`, `scope`, `code_challenge`, and `code_challenge_method=S256` at PAR. It requires a DPoP proof whose `htu` matches `/oauth/par`, consumes a Tempest-issued DPoP nonce, stores the proof -thumbprint as `dpop_jkt`, and stores the PAR row for ten minutes. It validates -the requested scope against a small allow-list: `atproto`, -`transition:generic`, `transition:chat.bsky`, `transition:email`, `blob:*/*`, -`rpc:*`, and `rpc:` forms. +thumbprint as `dpop_jkt`, and then fetches the client metadata document from +`client_id`. The metadata must match the `client_id`, register the submitted +redirect URI, include `code` and `authorization_code`, use public client +authentication, and opt into DPoP-bound access tokens. Tempest validates the +requested scope against its local allow-list and, when metadata declares a +scope string, against the client's registered scopes. PAR rows expire after ten +minutes. The authorization page authenticates a local account by handle, email, or DID, marks the PAR row used, creates a short-lived authorization code, and redirects back with `code` and optional `state`. Token exchange requires `code`, -`code_verifier`, matching redirect URI when supplied, S256 PKCE verification, -and a DPoP proof bound to the same `dpop_jkt`. Refresh rotates refresh tokens -and rejects already rotated or revoked rows. Access tokens are Phoenix tokens -backed by database token rows, with hashed access and refresh token storage. - -The largest missing piece is client metadata validation. Tempest advertises -`client_id_metadata_document_supported: true`, but it does not yet fetch the -`client_id` document, verify that the redirect URI was registered, enforce -metadata-declared scopes, validate `dpop_bound_access_tokens`, or support -`private_key_jwt`. It also does not expose token introspection. One smaller -interop mismatch is that Tempest accepts an omitted `redirect_uri` during token -exchange, while the stricter references require the exchange to repeat and -match the stored redirect URI. +`client_id`, `redirect_uri`, `code_verifier`, matching client and redirect URI, +S256 PKCE verification, and a DPoP proof bound to the same `dpop_jkt`. Refresh +requires the same client ID, rotates refresh tokens, and rejects already rotated +or revoked rows. Access tokens are Phoenix tokens backed by database token rows, +with hashed access and refresh token storage. + +Tempest does not support `private_key_jwt`, loopback development client metadata, +private-use redirect schemes, or token introspection. Tempest can issue DPoP-bound OAuth tokens through PAR and PKCE, but it still -trusts the caller's `client_id` and redirect URI more than Cocoon, Tranquil, or -ZDS. +implements a smaller client model than Cocoon, Tranquil, or ZDS. ## Client Compatibility diff --git a/lib/tempest/oauth.ex b/lib/tempest/oauth.ex index 0b3b654..5cb5afb 100644 --- a/lib/tempest/oauth.ex +++ b/lib/tempest/oauth.ex @@ -6,7 +6,7 @@ defmodule Tempest.OAuth do import Ecto.Query alias Tempest.Accounts.{Account, Password} - alias Tempest.OAuth.{AuthorizationCode, Dpop, ParRequest, Token} + alias Tempest.OAuth.{AuthorizationCode, ClientMetadata, Dpop, ParRequest, Token} alias Tempest.Repo alias TempestWeb.Endpoint @@ -24,9 +24,11 @@ defmodule Tempest.OAuth do :ok <- require_param(params, "redirect_uri"), :ok <- require_param(params, "scope"), :ok <- require_param(params, "code_challenge"), + "code" <- Map.get(params, "response_type"), "S256" <- Map.get(params, "code_challenge_method"), :ok <- validate_scope_string(params["scope"]), - {:ok, proof} <- Dpop.verify_proof(dpop_proof, "POST", public_url <> "/oauth/par") do + {:ok, proof} <- Dpop.verify_proof(dpop_proof, "POST", public_url <> "/oauth/par"), + {:ok, _client} <- ClientMetadata.fetch_for_par(params) do request_uri = "urn:ietf:params:oauth:request_uri:" <> random_token(32) now = now() @@ -108,9 +110,12 @@ defmodule Tempest.OAuth do def exchange_authorization_code(params, dpop_proof, public_url) do with :ok <- require_param(params, "code"), + :ok <- require_param(params, "client_id"), + :ok <- require_param(params, "redirect_uri"), :ok <- require_param(params, "code_verifier"), {:ok, code} <- fetch_code(params["code"]), - :ok <- verify_redirect_uri(code, Map.get(params, "redirect_uri")), + :ok <- verify_client_id(code, params["client_id"]), + :ok <- verify_redirect_uri(code, params["redirect_uri"]), :ok <- verify_pkce(code.code_challenge, params["code_verifier"]), {:ok, proof} <- Dpop.verify_proof(dpop_proof, "POST", public_url <> "/oauth/token", bound_jkt: code.dpop_jkt) do issue_tokens_from_code(code, proof) @@ -119,7 +124,9 @@ defmodule Tempest.OAuth do def refresh(params, dpop_proof, public_url) do with :ok <- require_param(params, "refresh_token"), + :ok <- require_param(params, "client_id"), {:ok, token} <- fetch_refresh_token(params["refresh_token"]), + :ok <- verify_client_id(token, params["client_id"]), {:ok, proof} <- Dpop.verify_proof(dpop_proof, "POST", public_url <> "/oauth/token", bound_jkt: token.dpop_jkt) do rotate_refresh_token(token, proof) end @@ -330,7 +337,9 @@ defmodule Tempest.OAuth do defp authenticate_account(_identifier, _password), do: {:error, :invalid_credentials} - defp verify_redirect_uri(_code, nil), do: :ok + defp verify_client_id(%{client_id: client_id}, client_id), do: :ok + defp verify_client_id(_credential, _client_id), do: {:error, :invalid_grant} + defp verify_redirect_uri(%AuthorizationCode{redirect_uri: redirect_uri}, redirect_uri), do: :ok defp verify_redirect_uri(_code, _redirect_uri), do: {:error, :invalid_grant} diff --git a/lib/tempest/oauth/client_metadata.ex b/lib/tempest/oauth/client_metadata.ex new file mode 100644 index 0000000..d21c20c --- /dev/null +++ b/lib/tempest/oauth/client_metadata.ex @@ -0,0 +1,136 @@ +defmodule Tempest.OAuth.ClientMetadata do + @moduledoc """ + OAuth client metadata fetch and validation. + + AT Protocol OAuth uses the `client_id` URL as the client metadata document. + This module handles the OAuth-specific semantics after the shared external + metadata fetcher has handled URL and network safety. + """ + + alias Tempest.Security.ExternalMetadataFetcher + + @max_body_bytes 64 * 1024 + + @type t :: %__MODULE__{ + client_id: String.t(), + redirect_uris: [String.t()], + scope: String.t() | nil, + token_endpoint_auth_method: String.t(), + dpop_bound_access_tokens: boolean() + } + + @enforce_keys [:client_id, :redirect_uris, :token_endpoint_auth_method, :dpop_bound_access_tokens] + defstruct [:client_id, :redirect_uris, :scope, :token_endpoint_auth_method, :dpop_bound_access_tokens] + + @doc """ + Fetches and validates client metadata for a PAR request. + """ + @spec fetch_for_par(map()) :: {:ok, t()} | {:error, atom()} + def fetch_for_par(params) when is_map(params) do + with {:ok, client_id} <- fetch_param(params, "client_id"), + {:ok, redirect_uri} <- fetch_param(params, "redirect_uri"), + {:ok, requested_scope} <- fetch_param(params, "scope"), + :ok <- validate_client_id_url(client_id), + {:ok, metadata} <- fetch_metadata(client_id), + {:ok, client} <- parse_metadata(metadata, client_id), + :ok <- validate_redirect_uri(client, redirect_uri), + :ok <- validate_requested_scope(client, requested_scope) do + {:ok, client} + end + end + + def fetch_for_par(_params), do: {:error, :invalid_client} + + defp fetch_metadata(client_id) do + case ExternalMetadataFetcher.fetch_json(client_id, max_body_bytes: @max_body_bytes) do + {:ok, metadata} when is_map(metadata) -> {:ok, metadata} + {:ok, _metadata} -> {:error, :invalid_client} + {:error, _reason} -> {:error, :invalid_client} + end + end + + defp parse_metadata(metadata, client_id) do + with ^client_id <- Map.get(metadata, "client_id"), + redirect_uris when is_list(redirect_uris) <- Map.get(metadata, "redirect_uris"), + true <- Enum.all?(redirect_uris, &valid_redirect_uri?/1), + true <- contains_string?(Map.get(metadata, "response_types"), "code"), + true <- contains_string?(Map.get(metadata, "grant_types"), "authorization_code"), + "none" <- Map.get(metadata, "token_endpoint_auth_method", "none"), + true <- Map.get(metadata, "dpop_bound_access_tokens") do + {:ok, + %__MODULE__{ + client_id: client_id, + redirect_uris: redirect_uris, + scope: metadata["scope"], + token_endpoint_auth_method: "none", + dpop_bound_access_tokens: true + }} + else + _reason -> {:error, :invalid_client} + end + end + + defp validate_client_id_url(client_id) do + case URI.parse(client_id) do + %URI{scheme: "https", host: host, userinfo: nil, fragment: nil} when is_binary(host) -> :ok + _uri -> {:error, :invalid_client} + end + end + + defp validate_redirect_uri(%__MODULE__{redirect_uris: redirect_uris}, redirect_uri) do + if redirect_uri in redirect_uris do + :ok + else + {:error, :invalid_request} + end + end + + defp validate_requested_scope(%__MODULE__{scope: nil}, _requested_scope), do: :ok + + defp validate_requested_scope(%__MODULE__{scope: client_scope}, requested_scope) when is_binary(client_scope) do + client_scopes = String.split(client_scope, " ", trim: true) + requested_scopes = String.split(requested_scope, " ", trim: true) + + if requested_scopes != [] and Enum.all?(requested_scopes, &scope_registered?(client_scopes, &1)) do + :ok + else + {:error, :invalid_scope} + end + end + + defp validate_requested_scope(_client, _requested_scope), do: {:error, :invalid_client} + + defp scope_registered?(client_scopes, requested_scope) do + Enum.any?(client_scopes, fn client_scope -> + client_scope == requested_scope or wildcard_scope_match?(client_scope, requested_scope) + end) + end + + defp wildcard_scope_match?(client_scope, requested_scope) do + client_base = scope_base(client_scope) + + String.contains?(client_base, "*") and scope_resource(client_base) == scope_resource(scope_base(requested_scope)) + end + + defp scope_base(scope), do: scope |> String.split("?", parts: 2) |> hd() + defp scope_resource(scope), do: scope |> String.split(":", parts: 2) |> hd() + + defp fetch_param(params, key) do + case Map.get(params, key) do + value when is_binary(value) and value != "" -> {:ok, value} + _value -> {:error, :invalid_request} + end + end + + defp contains_string?(values, value) when is_list(values), do: value in values + defp contains_string?(_values, _value), do: false + + defp valid_redirect_uri?(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{scheme: "https", host: host, fragment: nil} when is_binary(host) -> true + _uri -> false + end + end + + defp valid_redirect_uri?(_uri), do: false +end diff --git a/lib/tempest_web/controllers/oauth_controller.ex b/lib/tempest_web/controllers/oauth_controller.ex index 0f9a4ed..cf4d890 100644 --- a/lib/tempest_web/controllers/oauth_controller.ex +++ b/lib/tempest_web/controllers/oauth_controller.ex @@ -31,6 +31,9 @@ defmodule TempestWeb.OAuthController do {:error, :invalid_scope} -> conn |> put_status(400) |> json(%{"error" => "invalid_scope"}) + {:error, :invalid_client} -> + conn |> put_status(400) |> json(%{"error" => "invalid_client"}) + {:error, :rate_limited} -> conn |> put_status(429) |> json(%{"error" => "RateLimitExceeded"}) diff --git a/test/tempest/interop/local_server_sdk_test.exs b/test/tempest/interop/local_server_sdk_test.exs index f7a7eb2..382afa9 100644 --- a/test/tempest/interop/local_server_sdk_test.exs +++ b/test/tempest/interop/local_server_sdk_test.exs @@ -6,17 +6,36 @@ defmodule Tempest.Interop.LocalServerSdkTest do alias Tempest.AtprotoSdkClient alias Tempest.OAuth.Dpop alias Tempest.RepoCore.{Car, Cid, Drisl} + alias Tempest.Security.ExternalMetadataFetcher @base_url "http://localhost:4002" @password "correct horse battery staple" - @client_id "did:web:local-sdk-client.example.com" + @client_id "https://local-sdk-client.example.com/oauth/client-metadata.json" @redirect_uri "https://local-sdk-client.example.com/cb" - setup do + setup context do + Req.Test.set_req_test_from_context(context) + Req.Test.verify_on_exit!(context) + Tempest.DataCase.setup_sandbox(%{async: false}) + original_fetcher_config = Application.get_env(:tempest, ExternalMetadataFetcher, []) + + Application.put_env(:tempest, ExternalMetadataFetcher, + dns_lookup: fn "local-sdk-client.example.com" -> {:ok, [{93, 184, 216, 34}]} end, + req_options: [plug: {Req.Test, __MODULE__}] + ) + + Req.Test.stub(__MODULE__, fn conn -> + Req.Test.json(conn, client_metadata()) + end) + start_supervised!({Bandit, plug: TempestWeb.Endpoint, scheme: :http, ip: {127, 0, 0, 1}, port: 4002}) + on_exit(fn -> + Application.put_env(:tempest, ExternalMetadataFetcher, original_fetcher_config) + end) + {:ok, client: AtprotoSdkClient.new(@base_url)} end @@ -310,6 +329,20 @@ defmodule Tempest.Interop.LocalServerSdkTest do defp code_challenge(verifier), do: :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) + defp client_metadata do + %{ + "client_id" => @client_id, + "client_name" => "Local SDK Test Client", + "redirect_uris" => [@redirect_uri], + "grant_types" => ["authorization_code", "refresh_token"], + "response_types" => ["code"], + "scope" => "atproto", + "token_endpoint_auth_method" => "none", + "application_type" => "web", + "dpop_bound_access_tokens" => true + } + end + defp get_header(headers, name) do headers |> Map.fetch!(String.downcase(name)) diff --git a/test/tempest/oauth/client_metadata_test.exs b/test/tempest/oauth/client_metadata_test.exs new file mode 100644 index 0000000..2217e1d --- /dev/null +++ b/test/tempest/oauth/client_metadata_test.exs @@ -0,0 +1,119 @@ +defmodule Tempest.OAuth.ClientMetadataTest do + use ExUnit.Case, async: false + + alias Tempest.OAuth.ClientMetadata + alias Tempest.Security.ExternalMetadataFetcher + + @client_id "https://client.example.com/oauth/client-metadata.json" + @redirect_uri "https://client.example.com/callback" + + setup context do + Req.Test.set_req_test_from_context(context) + Req.Test.verify_on_exit!(context) + + original_fetcher_config = Application.get_env(:tempest, ExternalMetadataFetcher, []) + + Application.put_env(:tempest, ExternalMetadataFetcher, + dns_lookup: fn "client.example.com" -> {:ok, [{93, 184, 216, 34}]} end, + req_options: [plug: {Req.Test, __MODULE__}] + ) + + on_exit(fn -> + Application.put_env(:tempest, ExternalMetadataFetcher, original_fetcher_config) + end) + + :ok + end + + test "accepts public DPoP-bound client metadata registered for the request" do + Req.Test.expect(__MODULE__, fn conn -> + Req.Test.json(conn, metadata()) + end) + + assert {:ok, %ClientMetadata{} = client} = + ClientMetadata.fetch_for_par(%{ + "client_id" => @client_id, + "redirect_uri" => @redirect_uri, + "scope" => "atproto" + }) + + assert client.client_id == @client_id + assert client.redirect_uris == [@redirect_uri] + assert client.scope == "atproto rpc:*" + end + + test "rejects mismatched redirect_uri" do + Req.Test.expect(__MODULE__, fn conn -> + Req.Test.json(conn, metadata()) + end) + + assert {:error, :invalid_request} = + ClientMetadata.fetch_for_par(%{ + "client_id" => @client_id, + "redirect_uri" => "https://client.example.com/other", + "scope" => "atproto" + }) + end + + test "rejects unregistered requested scopes" do + Req.Test.expect(__MODULE__, fn conn -> + Req.Test.json(conn, metadata()) + end) + + assert {:error, :invalid_scope} = + ClientMetadata.fetch_for_par(%{ + "client_id" => @client_id, + "redirect_uri" => @redirect_uri, + "scope" => "transition:email" + }) + end + + test "allows wildcard scope registration within a resource family" do + Req.Test.expect(__MODULE__, fn conn -> + Req.Test.json(conn, metadata()) + end) + + assert {:ok, %ClientMetadata{}} = + ClientMetadata.fetch_for_par(%{ + "client_id" => @client_id, + "redirect_uri" => @redirect_uri, + "scope" => "rpc:com.atproto.server.getSession" + }) + end + + test "rejects unsupported client auth methods for now" do + Req.Test.expect(__MODULE__, fn conn -> + Req.Test.json(conn, Map.put(metadata(), "token_endpoint_auth_method", "private_key_jwt")) + end) + + assert {:error, :invalid_client} = + ClientMetadata.fetch_for_par(%{ + "client_id" => @client_id, + "redirect_uri" => @redirect_uri, + "scope" => "atproto" + }) + end + + test "rejects non-url client ids before fetching" do + assert {:error, :invalid_client} = + ClientMetadata.fetch_for_par(%{ + "client_id" => "did:web:client.example.com", + "redirect_uri" => @redirect_uri, + "scope" => "atproto" + }) + end + + defp metadata do + %{ + "client_id" => @client_id, + "client_name" => "Client Metadata Test", + "redirect_uris" => [@redirect_uri], + "grant_types" => ["authorization_code", "refresh_token"], + "response_types" => ["code"], + "scope" => "atproto rpc:*", + "token_endpoint_auth_method" => "none", + "application_type" => "web", + "dpop_bound_access_tokens" => true + } + end +end diff --git a/test/tempest_web/controllers/oauth_flow_test.exs b/test/tempest_web/controllers/oauth_flow_test.exs index fcd8970..cfabab4 100644 --- a/test/tempest_web/controllers/oauth_flow_test.exs +++ b/test/tempest_web/controllers/oauth_flow_test.exs @@ -3,12 +3,27 @@ defmodule TempestWeb.OAuthFlowTest do alias Tempest.Accounts alias Tempest.OAuth.Dpop + alias Tempest.Security.ExternalMetadataFetcher @password "correct horse battery staple" - @client_id "did:web:client.example.com" + @client_id "https://client.example.com/oauth/client-metadata.json" @redirect_uri "https://client.example.com/cb" - setup do + setup context do + Req.Test.set_req_test_from_context(context) + Req.Test.verify_on_exit!(context) + + original_fetcher_config = Application.get_env(:tempest, ExternalMetadataFetcher, []) + + Application.put_env(:tempest, ExternalMetadataFetcher, + dns_lookup: fn "client.example.com" -> {:ok, [{93, 184, 216, 34}]} end, + req_options: [plug: {Req.Test, __MODULE__}] + ) + + Req.Test.stub(__MODULE__, fn conn -> + Req.Test.json(conn, client_metadata()) + end) + {:ok, account} = Accounts.create_account(%{ "handle" => "oauth-flow-#{System.unique_integer([:positive])}.test", @@ -16,6 +31,10 @@ defmodule TempestWeb.OAuthFlowTest do "password" => @password }) + on_exit(fn -> + Application.put_env(:tempest, ExternalMetadataFetcher, original_fetcher_config) + end) + {:ok, account: account} end @@ -64,11 +83,32 @@ defmodule TempestWeb.OAuthFlowTest do assert token_response["scope"] == "atproto" assert token_response["sub"] == account["did"] assert is_binary(token_response["access_token"]) + assert is_binary(token_response["refresh_token"]) + + refresh_conn = + conn + |> recycle() + |> put_req_header("dpop", dpop("POST", "http://localhost:4002/oauth/token", Dpop.issue_nonce())) + |> post(~p"/oauth/token", %{ + "grant_type" => "refresh_token", + "client_id" => @client_id, + "refresh_token" => token_response["refresh_token"] + }) + + refreshed_response = json_response(refresh_conn, 200) + + assert refreshed_response["token_type"] == "DPoP" + assert refreshed_response["scope"] == "atproto" + assert refreshed_response["sub"] == account["did"] + assert is_binary(refreshed_response["access_token"]) + assert is_binary(refreshed_response["refresh_token"]) + refute refreshed_response["access_token"] == token_response["access_token"] + refute refreshed_response["refresh_token"] == token_response["refresh_token"] revoke_conn = conn |> recycle() - |> post(~p"/oauth/revoke", %{"token" => token_response["access_token"], "client_id" => @client_id}) + |> post(~p"/oauth/revoke", %{"token" => refreshed_response["access_token"], "client_id" => @client_id}) assert response(revoke_conn, 200) == "" end @@ -78,4 +118,18 @@ defmodule TempestWeb.OAuthFlowTest do defp code_challenge(verifier) do :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) end + + defp client_metadata do + %{ + "client_id" => @client_id, + "client_name" => "OAuth Flow Test Client", + "redirect_uris" => [@redirect_uri], + "grant_types" => ["authorization_code", "refresh_token"], + "response_types" => ["code"], + "scope" => "atproto", + "token_endpoint_auth_method" => "none", + "application_type" => "web", + "dpop_bound_access_tokens" => true + } + end end diff --git a/test/tempest_web/xrpc/actor_preferences_test.exs b/test/tempest_web/xrpc/actor_preferences_test.exs index 689a5e2..3c330b9 100644 --- a/test/tempest_web/xrpc/actor_preferences_test.exs +++ b/test/tempest_web/xrpc/actor_preferences_test.exs @@ -2,11 +2,34 @@ defmodule TempestWeb.Xrpc.ActorPreferencesTest do use TempestWeb.ConnCase, async: false alias Tempest.OAuth.Dpop + alias Tempest.Security.ExternalMetadataFetcher @password "correct horse battery staple" - @client_id "did:web:prefs-client.example.com" + @client_id "https://prefs-client.example.com/oauth/client-metadata.json" @redirect_uri "https://prefs-client.example.com/cb" + setup context do + Req.Test.set_req_test_from_context(context) + Req.Test.verify_on_exit!(context) + + original_fetcher_config = Application.get_env(:tempest, ExternalMetadataFetcher, []) + + Application.put_env(:tempest, ExternalMetadataFetcher, + dns_lookup: fn "prefs-client.example.com" -> {:ok, [{93, 184, 216, 34}]} end, + req_options: [plug: {Req.Test, __MODULE__}] + ) + + Req.Test.stub(__MODULE__, fn conn -> + Req.Test.json(conn, client_metadata()) + end) + + on_exit(fn -> + Application.put_env(:tempest, ExternalMetadataFetcher, original_fetcher_config) + end) + + :ok + end + test "getPreferences and putPreferences round-trip private account preferences", %{conn: conn} do account = create_account!(conn, "prefs-alice.test", "prefs-alice@example.com") @@ -209,4 +232,18 @@ defmodule TempestWeb.Xrpc.ActorPreferencesTest do :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) end + + defp client_metadata do + %{ + "client_id" => @client_id, + "client_name" => "Preferences Test Client", + "redirect_uris" => [@redirect_uri], + "grant_types" => ["authorization_code", "refresh_token"], + "response_types" => ["code"], + "scope" => "atproto", + "token_endpoint_auth_method" => "none", + "application_type" => "web", + "dpop_bound_access_tokens" => true + } + end end diff --git a/test/tempest_web/xrpc/compatibility_auth_content_test.exs b/test/tempest_web/xrpc/compatibility_auth_content_test.exs index ad884e3..9881df4 100644 --- a/test/tempest_web/xrpc/compatibility_auth_content_test.exs +++ b/test/tempest_web/xrpc/compatibility_auth_content_test.exs @@ -3,15 +3,31 @@ defmodule TempestWeb.Xrpc.CompatibilityAuthContentTest do alias Tempest.{Accounts, AdminAuth} alias Tempest.OAuth.Dpop + alias Tempest.Security.ExternalMetadataFetcher @password "correct horse battery staple" - @client_id "did:web:compat-client.example.com" + @client_id "https://compat-client.example.com/oauth/client-metadata.json" @redirect_uri "https://compat-client.example.com/cb" - setup do + setup context do + Req.Test.set_req_test_from_context(context) + Req.Test.verify_on_exit!(context) + old_hash = Application.get_env(:tempest, :admin_token_hash) + original_fetcher_config = Application.get_env(:tempest, ExternalMetadataFetcher, []) + + Application.put_env(:tempest, ExternalMetadataFetcher, + dns_lookup: fn "compat-client.example.com" -> {:ok, [{93, 184, 216, 34}]} end, + req_options: [plug: {Req.Test, __MODULE__}] + ) + + Req.Test.stub(__MODULE__, fn conn -> + Req.Test.json(conn, client_metadata()) + end) on_exit(fn -> + Application.put_env(:tempest, ExternalMetadataFetcher, original_fetcher_config) + if old_hash do Application.put_env(:tempest, :admin_token_hash, old_hash) else @@ -311,6 +327,20 @@ defmodule TempestWeb.Xrpc.CompatibilityAuthContentTest do :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) end + defp client_metadata do + %{ + "client_id" => @client_id, + "client_name" => "Compatibility Auth Test Client", + "redirect_uris" => [@redirect_uri], + "grant_types" => ["authorization_code", "refresh_token"], + "response_types" => ["code"], + "scope" => "atproto", + "token_endpoint_auth_method" => "none", + "application_type" => "web", + "dpop_bound_access_tokens" => true + } + end + defp assert_error(conn, status, error, message) do response = json_response(conn, status) diff --git a/test/tempest_web/xrpc/plc_identity_test.exs b/test/tempest_web/xrpc/plc_identity_test.exs index f966f68..1f299dc 100644 --- a/test/tempest_web/xrpc/plc_identity_test.exs +++ b/test/tempest_web/xrpc/plc_identity_test.exs @@ -12,11 +12,12 @@ defmodule TempestWeb.Xrpc.PlcIdentityTest do alias Tempest.Repo alias Tempest.Security alias Tempest.OAuth.Dpop + alias Tempest.Security.ExternalMetadataFetcher alias Tempest.Security.SecurityEvent alias Tempest.Sequencer @password "correct horse battery staple" - @client_id "did:web:plc-identity-client.example.com" + @client_id "https://plc-identity-client.example.com/oauth/client-metadata.json" @redirect_uri "https://plc-identity-client.example.com/cb" setup context do @@ -25,9 +26,20 @@ defmodule TempestWeb.Xrpc.PlcIdentityTest do old_identity_config = Application.get_env(:tempest, Tempest.Identity, []) old_admin_hash = Application.get_env(:tempest, :admin_token_hash) + original_fetcher_config = Application.get_env(:tempest, ExternalMetadataFetcher, []) + + Application.put_env(:tempest, ExternalMetadataFetcher, + dns_lookup: fn "plc-identity-client.example.com" -> {:ok, [{93, 184, 216, 34}]} end, + req_options: [plug: {Req.Test, __MODULE__}] + ) + + Req.Test.stub(__MODULE__, fn conn -> + Req.Test.json(conn, client_metadata()) + end) on_exit(fn -> Application.put_env(:tempest, Tempest.Identity, old_identity_config) + Application.put_env(:tempest, ExternalMetadataFetcher, original_fetcher_config) if old_admin_hash do Application.put_env(:tempest, :admin_token_hash, old_admin_hash) @@ -656,6 +668,20 @@ defmodule TempestWeb.Xrpc.PlcIdentityTest do :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) end + defp client_metadata do + %{ + "client_id" => @client_id, + "client_name" => "PLC Identity Test Client", + "redirect_uris" => [@redirect_uri], + "grant_types" => ["authorization_code", "refresh_token"], + "response_types" => ["code"], + "scope" => "atproto", + "token_endpoint_auth_method" => "none", + "application_type" => "web", + "dpop_bound_access_tokens" => true + } + end + defp assert_error(conn, status, error, message) do response = json_response(conn, status) -- 2.51.2