From 7b90565bcf11da3f8bbc101040f6d313d7643b89 Mon Sep 17 00:00:00 2001 From: Johanna Larsson Date: Sun, 2 Aug 2026 17:07:50 +0100 Subject: [PATCH] Build URLs at runtime This is a breaking change that introduces a new required parameter that dynamically returns the base URL for client_id/redirect_uri. This is motivated by how painful it is to configure Latch ATM, both for local dev where you might temporarily change your Phoenix host config, and you'd then need to rewrite the client_id/redirect_uri, but also because in most cases in production your actual host is read from the environment, at runtime. Some examples: ```elixir fn -> "https://example.com" end fn -> MyAppWeb.Endpoint.url() end fn -> System.get_env("APP_URL") end ``` --- README.md | 6 ++- lib/latch.ex | 24 ++++++----- lib/latch/client.ex | 2 +- lib/latch/config.ex | 71 +++++++++++++++++++++------------ test/latch/client_test.exs | 13 +++--- test/latch/config_test.exs | 19 +++++---- test/latch/dpop_test.exs | 14 ++++--- test/latch/flow_test.exs | 15 +++---- test/latch/nonce_cache_test.exs | 7 ++-- test/latch/store/ets_test.exs | 11 ++--- test/latch/xrpc_test.exs | 7 ++-- test/latch_test.exs | 65 +++++++++++++++++------------- 12 files changed, 145 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 7212ff8..8709993 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,10 @@ Add `Latch` to your supervision tree, giving it a unique name and a `Latch.Store name: MyApp.Latch, mode: :confidential, store: MyApp.LatchStore, - client_id: "https://myapp.example/oauth-client-metadata.json", - redirect_uri: "https://myapp.example/auth/callback", + client_id_path: "/oauth-client-metadata.json", + redirect_uri_path: "/auth/callback", + # Dynamically resolve the base URL at runtime, or pass a hard-coded URL + base_url_fun: fn -> MyApp.Endpoint.url() end, scope: "atproto", signing_key: System.fetch_env!("ATPROTO_CLIENT_PRIVATE_JWK")} ] diff --git a/lib/latch.ex b/lib/latch.ex index 546af0b..4e0a00d 100644 --- a/lib/latch.ex +++ b/lib/latch.ex @@ -37,7 +37,9 @@ defmodule Latch do ## Examples - iex> {:ok, _pid} = Latch.start_link(name: LatchStartLinkExample, store: Latch.TestStore, mode: :confidential, client_id: "https://myapp.example/metadata.json", redirect_uri: "https://myapp.example/callback", scope: "atproto", signing_key: Jason.encode!(Latch.DPoP.generate_key())) + iex> {:ok, pid} = Latch.start_link(name: LatchStartLinkExample, store: Latch.TestStore, mode: :confidential, client_id_path: "/metadata.json", redirect_uri_path: "/callback", base_url_fun: fn -> "https://example.com" end, scope: "atproto", signing_key: Jason.encode!(Latch.DPoP.generate_key())) + iex> is_pid(pid) + true """ def start_link(opts) do name = Keyword.fetch!(opts, :name) @@ -51,8 +53,8 @@ defmodule Latch do ## Examples - iex> Latch.child_spec(name: MyApp.Latch, store: MyApp.Store, client_id: "https://myapp.example/metadata.json", redirect_uri: "https://myapp.example/callback", scope: "atproto", signing_key: :test_key, mode: :confidential) - %{id: MyApp.Latch, start: {Latch, :start_link, [[name: MyApp.Latch, store: MyApp.Store, client_id: "https://myapp.example/metadata.json", redirect_uri: "https://myapp.example/callback", scope: "atproto", signing_key: :test_key, mode: :confidential]]}, type: :supervisor} + iex> %{id: MyApp.Latch, type: :supervisor, start: {Latch, :start_link, [_opts]}} = + ...> Latch.child_spec(name: MyApp.Latch, store: MyApp.Store, client_id_path: "/metadata.json", redirect_uri_path: "/callback", scope: "atproto", signing_key: :test_key, mode: :confidential, base_url_fun: fn -> "https://example.com" end) """ def child_spec(opts) do name = Keyword.fetch!(opts, :name) @@ -88,8 +90,8 @@ defmodule Latch do %Config{} = config = config(name) ClientMetadata.build( - client_id: config.client_id, - redirect_uris: [config.redirect_uri], + client_id: Config.client_id(config), + redirect_uris: [Config.redirect_uri(config)], scope: config.scope, jwk: config.signing_key, client_name: config.client_name, @@ -110,7 +112,7 @@ defmodule Latch do ## Examples - iex> {:ok, _pid} = Latch.start_link(mode: :confidential, name: LatchAuthorizeExample, store: Latch.TestStore, client_id: "https://myapp.example/metadata.json", redirect_uri: "https://myapp.example/callback", scope: "atproto", signing_key: Jason.encode!(Latch.DPoP.generate_key())) + iex> {:ok, _pid} = Latch.start_link(mode: :confidential, name: LatchAuthorizeExample, store: Latch.TestStore, client_id_path: "/metadata.json", redirect_uri_path: "/callback", base_url_fun: fn -> "https://example.com" end, scope: "atproto", signing_key: Jason.encode!(Latch.DPoP.generate_key())) iex> Latch.authorize(LatchAuthorizeExample, "not a handle") {:error, %Latch.Error.HandleNotFound{handle: "not a handle", reason: :invalid_handle}} """ @@ -137,9 +139,9 @@ defmodule Latch do Discovery.discover(identity.pds_endpoint, allow_http: Config.localhost?(config)), {:ok, request_uri} <- Flow.par(config, server, - client_id: config.client_id, + client_id: Config.client_id(config), client_jwk: config.signing_key, - redirect_uri: config.redirect_uri, + redirect_uri: Config.redirect_uri(config), scope: config.scope, state: state, code_challenge: PKCE.challenge(verifier), @@ -157,7 +159,7 @@ defmodule Latch do dpop_key: dpop_key }, :ok <- store_request(config, request) do - {:ok, Flow.authorization_url(server, config.client_id, request_uri)} + {:ok, Flow.authorization_url(server, Config.client_id(config), request_uri)} end end @@ -319,9 +321,9 @@ defmodule Latch do with :ok <- verify_issuer(request, issuer), {:ok, session} <- Flow.exchange_code(config, - client_id: config.client_id, + client_id: Config.client_id(config), client_jwk: config.signing_key, - redirect_uri: config.redirect_uri, + redirect_uri: Config.redirect_uri(config), code: code, code_verifier: request.pkce_verifier, dpop_key: request.dpop_key, diff --git a/lib/latch/client.ex b/lib/latch/client.ex index 9042cd4..aa42284 100644 --- a/lib/latch/client.ex +++ b/lib/latch/client.ex @@ -104,7 +104,7 @@ defmodule Latch.Client do with {:ok, server} <- Discovery.discover(session.pds_endpoint, allow_http: Config.localhost?(config)) do Flow.refresh(config, server, session, - client_id: config.client_id, + client_id: Config.client_id(config), client_jwk: config.signing_key ) end diff --git a/lib/latch/config.ex b/lib/latch/config.ex index 38fe631..38146d5 100644 --- a/lib/latch/config.ex +++ b/lib/latch/config.ex @@ -1,25 +1,26 @@ defmodule Latch.Config do @moduledoc """ - The configuration that drives the client. + The internal configuration that drives the library. ## Fields * `:store` - a module implementing `Latch.Store` * `:name` - the name of the Latch instance * `:mode` - `:confidential`, `:public`, or `:localhost` - * `:redirect_uri` - the OAuth callback URL + * `:redirect_uri_path` - the OAuth callback path * `:scope` - the requsted scopes - * `:client_id` - the URL of the published client metadata document + * `:client_id_path` - the path of the published client metadata document * `:signing_key` - the JSON-encoded ES256 private JWK for `private_key_jwt` as string * `:client_name` - shown on the authorization consent screen * `:client_uri` - client home page + * `:base_url_fun` - function to build the base URL for `redirect_uri` and `client_id`, because the host is frequently only known at runtime. """ @default_request_ttl 600 - @enforce_keys [:store, :redirect_uri, :scope, :name, :mode] + @enforce_keys [:store, :redirect_uri_path, :scope, :name, :mode, :base_url_fun] defstruct @enforce_keys ++ [ - :client_id, + :client_id_path, :signing_key, :client_name, :client_uri, @@ -31,8 +32,9 @@ defmodule Latch.Config do @type t :: %__MODULE__{ store: module(), mode: mode(), - client_id: String.t() | nil, - redirect_uri: String.t(), + client_id_path: String.t() | nil, + redirect_uri_path: String.t(), + base_url_fun: (-> String.t()), scope: String.t(), signing_key: map() | nil, name: atom() | pid(), @@ -43,10 +45,11 @@ defmodule Latch.Config do @modes [:confidential, :public, :localhost] - @schema [ + @input_schema [ store: [type: :atom, required: true], - client_id: [type: :string, required: false], - redirect_uri: [type: :string, required: true], + client_id_path: [type: :string, required: false], + redirect_uri_path: [type: :string, required: true], + base_url_fun: [type: {:fun, 0}, required: true], scope: [type: :string, required: true], signing_key: [type: :string, required: false], name: [type: {:or, [:atom, :pid]}, required: true], @@ -58,14 +61,15 @@ defmodule Latch.Config do @doc false def build!(opts) when is_list(opts) do - validated = NimbleOptions.validate!(opts, @schema) + validated = NimbleOptions.validate!(opts, @input_schema) mode = validated[:mode] struct!( __MODULE__, store: validated[:store], - client_id: client_id!(mode, validated), - redirect_uri: validated[:redirect_uri], + client_id_path: client_id_path!(mode, validated), + redirect_uri_path: validated[:redirect_uri_path], + base_url_fun: validated[:base_url_fun], scope: validated[:scope], signing_key: signing_key!(mode, validated), name: validated[:name], @@ -82,27 +86,42 @@ defmodule Latch.Config do def localhost?(%__MODULE__{mode: :localhost}), do: true def localhost?(%__MODULE__{}), do: false - defp client_id!(:localhost, validated) do - if client_id = validated[:client_id] do + def client_id(%__MODULE__{ + mode: :localhost, + redirect_uri_path: redirect_uri_path, + scope: scope, + base_url_fun: base_url_fun + }) do + redirect_uri = base_url_fun.() <> redirect_uri_path + "http://localhost?" <> URI.encode_query(redirect_uri: redirect_uri, scope: scope) + end + + def client_id(%__MODULE{client_id_path: client_id_path, base_url_fun: base_url_fun}) do + base_url_fun.() <> client_id_path + end + + def redirect_uri(%__MODULE__{redirect_uri_path: redirect_uri_path, base_url_fun: base_url_fun}) do + base_url_fun.() <> redirect_uri_path + end + + defp client_id_path!(:localhost, validated) do + if client_id_path = validated[:client_id_path] do error( - :client_id, - client_id, - "invalid value for :client_id option, not allowed when mode is :localhost" + :client_id_path, + client_id_path, + "invalid value for :client_id_path option, not allowed when mode is :localhost" ) end - - "http://localhost?" <> - URI.encode_query(redirect_uri: validated[:redirect_uri], scope: validated[:scope]) end - defp client_id!(_mode, validated) do - if client_id = validated[:client_id] do - client_id + defp client_id_path!(_mode, validated) do + if client_id_path = validated[:client_id_path] do + client_id_path else error( - :client_id, + :client_id_path, nil, - "required :client_id option not found, received options: #{inspect(Keyword.keys(validated))}" + "required :client_id_path option not found, received options: #{inspect(Keyword.keys(validated))}" ) end end diff --git a/test/latch/client_test.exs b/test/latch/client_test.exs index b26abaf..77aca8b 100644 --- a/test/latch/client_test.exs +++ b/test/latch/client_test.exs @@ -14,8 +14,8 @@ defmodule Latch.ClientTest do @did "did:plc:bvraa6gajy4tfr3eh2sisdkr" @pds "https://pds.example.com" @issuer "https://issuer.example.com" - @client_id "https://client.example.com/oauth-client-metadata.json" - @redirect_uri "https://client.example.com/oauth/callback" + @client_id_path "/oauth-client-metadata.json" + @redirect_uri_path "/oauth/callback" describe "query/3" do test "refreshes an expired session before making an XRPC query" do @@ -29,7 +29,7 @@ defmodule Latch.ClientTest do expect(Discovery, :discover, fn @pds, _opts -> {:ok, server} end) expect(Flow, :refresh, fn _config, ^server, ^stale_session, opts -> - assert opts[:client_id] == config.client_id + assert opts[:client_id] == Latch.Config.client_id(config) assert opts[:client_jwk] == config.signing_key {:ok, refreshed_session} end) @@ -139,12 +139,13 @@ defmodule Latch.ClientTest do defp make_config do %Config{ store: Latch.TestStore, - client_id: @client_id, - redirect_uri: @redirect_uri, + client_id_path: @client_id_path, + redirect_uri_path: @redirect_uri_path, scope: "atproto", signing_key: ~s({"kty":"EC"}), name: :name, - mode: :confidential + mode: :confidential, + base_url_fun: fn -> "https://client.example.com" end } end end diff --git a/test/latch/config_test.exs b/test/latch/config_test.exs index 6b4fbcd..0db2e73 100644 --- a/test/latch/config_test.exs +++ b/test/latch/config_test.exs @@ -4,8 +4,10 @@ defmodule Latch.ConfigTest do alias Latch.Config @store Latch.TestStore - @client_id "client_id" - @redirect_uri "redirect_uri" + @client_id_path "/client_id" + @client_id "https://client.example.com" <> @client_id_path + @redirect_uri_path "/redirect_uri" + @redirect_uri "https://client.example.com" <> @redirect_uri_path @scope "atproto something" @signing_key ~s({"kty":"EC"}) @name :name @@ -17,8 +19,8 @@ defmodule Latch.ConfigTest do assert %Config{} = config = Config.build!(opts([])) assert config.store == @store - assert config.client_id == @client_id - assert config.redirect_uri == @redirect_uri + assert Config.client_id(config) == @client_id + assert Config.redirect_uri(config) == @redirect_uri assert config.scope == @scope assert config.signing_key == Jason.decode!(@signing_key) assert config.name == @name @@ -73,7 +75,7 @@ defmodule Latch.ConfigTest do fn -> [mode: :localhost] |> opts() - |> Keyword.delete(:client_id) + |> Keyword.delete(:client_id_path) |> Config.build!() end end @@ -83,14 +85,15 @@ defmodule Latch.ConfigTest do Keyword.merge( [ store: @store, - client_id: @client_id, - redirect_uri: @redirect_uri, + client_id_path: @client_id_path, + redirect_uri_path: @redirect_uri_path, scope: @scope, signing_key: @signing_key, name: @name, client_name: @client_name, client_uri: @client_uri, - mode: :confidential + mode: :confidential, + base_url_fun: fn -> "https://client.example.com" end ], overrides ) diff --git a/test/latch/dpop_test.exs b/test/latch/dpop_test.exs index 44a213e..3baf209 100644 --- a/test/latch/dpop_test.exs +++ b/test/latch/dpop_test.exs @@ -9,12 +9,13 @@ defmodule Latch.DPoPTest do test "retry with nonce after challenge" do config = %Config{ store: Latch.TestStore, - client_id: "client_id", - redirect_uri: "direct_uri", + client_id_path: "/client_id", + redirect_uri_path: "/direct_uri", scope: "atproto", signing_key: nil, name: :"#{inspect(self())}", - mode: :confidential + mode: :confidential, + base_url_fun: fn -> "https://example.com" end } start_link_supervised!( @@ -48,12 +49,13 @@ defmodule Latch.DPoPTest do test "uses cached nonce" do config = %Config{ store: Latch.TestStore, - client_id: "client_id", - redirect_uri: "direct_uri", + client_id_path: "/client_id", + redirect_uri_path: "/direct_uri", scope: "atproto", signing_key: nil, name: :"#{inspect(self())}", - mode: :confidential + mode: :confidential, + base_url_fun: fn -> "https://example.com" end } start_link_supervised!( diff --git a/test/latch/flow_test.exs b/test/latch/flow_test.exs index d8b6b5c..731e8b2 100644 --- a/test/latch/flow_test.exs +++ b/test/latch/flow_test.exs @@ -218,14 +218,8 @@ defmodule Latch.FlowTest do test "localhost client omits client_assertion" do dpop_key = DPoP.generate_key() - redirect_uri = "http://127.0.0.1/callback" - config = - make_config( - mode: :localhost, - client_id: - "http://localhost?redirect_uri=#{URI.encode_www_form(redirect_uri)}&scope=atproto" - ) + make_config(mode: :localhost) start_link_supervised!( {Latch.NonceCache, config: config, name: config.name, sweep_disabled: true} @@ -297,12 +291,13 @@ defmodule Latch.FlowTest do defp make_config(overrides \\ []) do defaults = [ store: Latch.TestStore, - client_id: "https://client.example.com/oauth-client-metadata.json", - redirect_uri: "https://client.example.com/oauth/callback", + client_id_path: "/oauth-client-metadata.json", + redirect_uri_path: "/oauth/callback", scope: "atproto", signing_key: Jason.decode!(Jason.encode!(Latch.DPoP.generate_key())), name: :"flow_test_#{inspect(self())}", - mode: :confidential + mode: :confidential, + base_url_fun: fn -> "https://client.example.com" end ] attrs = Keyword.merge(defaults, overrides) diff --git a/test/latch/nonce_cache_test.exs b/test/latch/nonce_cache_test.exs index 37b5274..036d943 100644 --- a/test/latch/nonce_cache_test.exs +++ b/test/latch/nonce_cache_test.exs @@ -77,12 +77,13 @@ defmodule Latch.NonceCacheTest do defp config(name) do %Config{ store: Latch.TestStore, - client_id: "client-id", - redirect_uri: "redirect-uri", + client_id_path: "/client-id", + redirect_uri_path: "/redirect-uri", scope: "atproto", signing_key: ~s({"kty":"EC"}), name: name, - mode: :confidential + mode: :confidential, + base_url_fun: fn -> "https://example.com" end } end end diff --git a/test/latch/store/ets_test.exs b/test/latch/store/ets_test.exs index caffaad..8ebbb6a 100644 --- a/test/latch/store/ets_test.exs +++ b/test/latch/store/ets_test.exs @@ -12,8 +12,8 @@ defmodule Latch.Store.ETSTest do @did "did:plc:bvraa6gajy4tfr3eh2sisdkr" @handle "jola.dev" - @client_id "https://client.example.com/oauth-client-metadata.json" - @redirect_uri "https://client.example.com/oauth/callback" + @client_id_path "/oauth-client-metadata.json" + @redirect_uri_path "/oauth/callback" @issuer "https://issuer.example.com" @pds "https://pds.example.com" @dpop_key DPoP.generate_key() @@ -344,11 +344,12 @@ defmodule Latch.Store.ETSTest do [ name: name, store: Latch.ETSStore, - client_id: @client_id, - redirect_uri: @redirect_uri, + client_id_path: @client_id_path, + redirect_uri_path: @redirect_uri_path, scope: "atproto", signing_key: Jason.encode!(@dpop_key), - mode: :confidential + mode: :confidential, + base_url_fun: fn -> "https://client.example.com" end ], overrides ) diff --git a/test/latch/xrpc_test.exs b/test/latch/xrpc_test.exs index 0bfe4b1..45262bb 100644 --- a/test/latch/xrpc_test.exs +++ b/test/latch/xrpc_test.exs @@ -126,12 +126,13 @@ defmodule Latch.XRPCTest do defp make_config(overrides \\ []) do defaults = [ store: Latch.TestStore, - client_id: "https://client.example.com/oauth-client-metadata.json", - redirect_uri: "https://client.example.com/oauth/callback", + client_id_path: "/oauth-client-metadata.json", + redirect_uri_path: "/oauth/callback", scope: "atproto", signing_key: Jason.decode!(Jason.encode!(DPoP.generate_key())), name: :"flow_test_#{inspect(self())}", - mode: :confidential + mode: :confidential, + base_url_fun: fn -> "https://client.example.com" end ] attrs = Keyword.merge(defaults, overrides) diff --git a/test/latch_test.exs b/test/latch_test.exs index 77b41e9..f7a7250 100644 --- a/test/latch_test.exs +++ b/test/latch_test.exs @@ -17,7 +17,8 @@ defmodule LatchTest do @pds "https://pds.example.com" @issuer "https://issuer.example.com" @client_id "https://client.example.com/oauth-client-metadata.json" - @redirect_uri "https://client.example.com/oauth/callback" + @client_id_path "/oauth-client-metadata.json" + @redirect_uri_path "/oauth/callback" describe "authorize/2" do test "resolves identity, creates PAR, stores the request, and returns the redirect URL" do @@ -26,11 +27,12 @@ defmodule LatchTest do pid = start_latch( store: Latch.TestStore, - client_id: @client_id, - redirect_uri: @redirect_uri, + client_id_path: @client_id_path, + redirect_uri_path: @redirect_uri_path, scope: "atproto", signing_key: Jason.encode!(DPoP.generate_key()), - mode: :confidential + mode: :confidential, + base_url_fun: fn -> "https://client.example.com" end ) identity = %Identity{did: @did, handle: @handle, pds_endpoint: @pds} @@ -40,8 +42,8 @@ defmodule LatchTest do expect(Discovery, :discover, fn @pds, _opts -> {:ok, server} end) expect(Flow, :par, fn config, ^server, opts -> - assert opts[:client_id] == config.client_id - assert opts[:redirect_uri] == config.redirect_uri + assert opts[:client_id] == Latch.Config.client_id(config) + assert opts[:redirect_uri] == Latch.Config.redirect_uri(config) assert opts[:scope] == config.scope assert opts[:login_hint] == @handle assert is_binary(opts[:state]) @@ -87,11 +89,12 @@ defmodule LatchTest do pid = start_latch( store: Latch.TestStore, - client_id: @client_id, - redirect_uri: @redirect_uri, + client_id_path: @client_id_path, + redirect_uri_path: @redirect_uri_path, scope: "atproto", signing_key: Jason.encode!(DPoP.generate_key()), - mode: :confidential + mode: :confidential, + base_url_fun: fn -> "https://client.example.com" end ) request = %Request{ @@ -149,11 +152,12 @@ defmodule LatchTest do pid = start_latch( store: Latch.TestStore, - client_id: @client_id, - redirect_uri: @redirect_uri, + client_id_path: @client_id_path, + redirect_uri_path: @redirect_uri_path, scope: "atproto", signing_key: Jason.encode!(DPoP.generate_key()), - mode: :confidential + mode: :confidential, + base_url_fun: fn -> "https://client.example.com" end ) expect(Client, :query, fn _config, @@ -173,11 +177,12 @@ defmodule LatchTest do pid = start_latch( store: Latch.TestStore, - client_id: @client_id, - redirect_uri: @redirect_uri, + client_id_path: @client_id_path, + redirect_uri_path: @redirect_uri_path, scope: "atproto", signing_key: Jason.encode!(DPoP.generate_key()), - mode: :confidential + mode: :confidential, + base_url_fun: fn -> "https://client.example.com" end ) body = %{ @@ -200,11 +205,12 @@ defmodule LatchTest do pid = start_latch( store: Latch.TestStore, - client_id: @client_id, - redirect_uri: @redirect_uri, + client_id_path: @client_id_path, + redirect_uri_path: @redirect_uri_path, scope: "atproto", signing_key: Jason.encode!(DPoP.generate_key()), - mode: :confidential + mode: :confidential, + base_url_fun: fn -> "https://client.example.com" end ) expect(Client, :upload_blob, fn _config, @did, <<1, 2, 3>>, "image/png", [] -> @@ -220,11 +226,12 @@ defmodule LatchTest do pid = start_latch( store: Latch.TestStore, - client_id: @client_id, - redirect_uri: @redirect_uri, + client_id_path: @client_id_path, + redirect_uri_path: @redirect_uri_path, scope: "atproto", signing_key: Jason.encode!(DPoP.generate_key()), - mode: :confidential + mode: :confidential, + base_url_fun: fn -> "https://client.example.com" end ) assert %{ @@ -255,10 +262,11 @@ defmodule LatchTest do pid = start_latch( store: Latch.TestStore, - client_id: @client_id, - redirect_uri: @redirect_uri, + client_id_path: @client_id_path, + redirect_uri_path: @redirect_uri_path, scope: "atproto", - mode: :public + mode: :public, + base_url_fun: fn -> "https://client.example.com" end ) assert %{ @@ -277,18 +285,19 @@ defmodule LatchTest do pid = start_latch( store: Latch.TestStore, - redirect_uri: @redirect_uri, + redirect_uri_path: @redirect_uri_path, scope: "atproto", - mode: :localhost + mode: :localhost, + base_url_fun: fn -> "http://localhost" end ) assert %{ "application_type" => "native", "client_id" => - "http://localhost?redirect_uri=https%3A%2F%2Fclient.example.com%2Foauth%2Fcallback&scope=atproto", + "http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2Foauth%2Fcallback&scope=atproto", "dpop_bound_access_tokens" => true, "grant_types" => ["authorization_code", "refresh_token"], - "redirect_uris" => ["https://client.example.com/oauth/callback"], + "redirect_uris" => ["http://localhost/oauth/callback"], "response_types" => ["code"], "scope" => "atproto", "token_endpoint_auth_method" => "none" -- 2.51.2