diff --git a/README.md b/README.md index d4e7f6d..f1de54d 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,10 @@ be found at . - [x] Confidential client - [x] DPoP nonce caching -- [ ] Public client -- [ ] Local client +- [x] Public client +- [x] Local client +- [ ] Built-in ETS LatchStore implementation +- [ ] Getting started guide - [ ] Extensive tests ## Specification references diff --git a/lib/latch.ex b/lib/latch.ex index 7d3e09c..4bc9eb9 100644 --- a/lib/latch.ex +++ b/lib/latch.ex @@ -25,7 +25,8 @@ defmodule Latch do client_id: "https://myapp.example/oauth-client-metadata.json", redirect_uri: "https://myapp.example/auth/callback", scope: "atproto", - signing_key: "key"} + signing_key: "key"}, + mode: :confidential ] Optional keys: `:client_name`, `:client_uri` and `request_ttl`. @@ -90,6 +91,10 @@ defmodule Latch do Starts a Latch supervisor. See the module documentation for the supported options. + + ## 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())) """ def start_link(opts) do name = Keyword.fetch!(opts, :name) @@ -103,8 +108,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) - %{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]]}, type: :supervisor} + 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} """ def child_spec(opts) do name = Keyword.fetch!(opts, :name) @@ -145,7 +150,8 @@ defmodule Latch do scope: config.scope, jwk: config.signing_key, client_name: config.client_name, - client_uri: config.client_uri + client_uri: config.client_uri, + mode: config.mode ) end @@ -161,7 +167,7 @@ defmodule Latch do ## Examples - iex> {:ok, _pid} = Latch.start_link(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: "https://myapp.example/metadata.json", redirect_uri: "https://myapp.example/callback", 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}} """ @@ -184,7 +190,8 @@ defmodule Latch do state = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) with {:ok, identity} <- Identity.resolve_handle(handle), - {:ok, server} <- Discovery.discover(identity.pds_endpoint), + {:ok, server} <- + Discovery.discover(identity.pds_endpoint, allow_http: Config.localhost?(config)), {:ok, request_uri} <- Flow.par(config, server, client_id: config.client_id, diff --git a/lib/latch/client.ex b/lib/latch/client.ex index a65cb2e..c4c73f0 100644 --- a/lib/latch/client.ex +++ b/lib/latch/client.ex @@ -99,7 +99,8 @@ defmodule Latch.Client do defp do_refresh(config, session) do result = - with {:ok, server} <- Discovery.discover(session.pds_endpoint) 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_jwk: config.signing_key diff --git a/lib/latch/client_metadata.ex b/lib/latch/client_metadata.ex index 0c58f44..8745a2f 100644 --- a/lib/latch/client_metadata.ex +++ b/lib/latch/client_metadata.ex @@ -20,6 +20,7 @@ defmodule Latch.ClientMetadata do - `:jwk` (required) - client signing key, only the public part is published - `:client_name` (optional) - `:client_uri` (optional) - must share the `client_id` hostname + - `:mode` - `:confidential`, `:public` or `:localhost` """ @spec build(keyword()) :: t() def build(opts) do @@ -33,22 +34,22 @@ defmodule Latch.ClientMetadata do redirect_uris = Keyword.fetch!(opts, :redirect_uris) client_name = Keyword.get(opts, :client_name) client_uri = Keyword.get(opts, :client_uri) - jwk = Keyword.fetch!(opts, :jwk) + mode = Keyword.fetch!(opts, :mode) %{ "client_id" => client_id, - "application_type" => "web", + "application_type" => application_type(mode), "grant_types" => ["authorization_code", "refresh_token"], "response_types" => ["code"], "redirect_uris" => redirect_uris, "scope" => scope, "dpop_bound_access_tokens" => true, - "token_endpoint_auth_method" => "private_key_jwt", - "token_endpoint_auth_signing_alg" => "ES256", - "jwks" => %{"keys" => [public_jwk(jwk)]} + "token_endpoint_auth_method" => auth_method(mode) } |> maybe_put("client_name", client_name) |> maybe_put("client_uri", client_uri) + |> maybe_put("token_endpoint_auth_signing_alg", auth_alg(mode)) + |> maybe_put("jwks", jwks(mode, opts)) end defp public_jwk(key_map) do @@ -59,4 +60,20 @@ defmodule Latch.ClientMetadata do defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp application_type(:localhost), do: "native" + defp application_type(_mode), do: "web" + + defp auth_method(:confidential), do: "private_key_jwt" + defp auth_method(_mode), do: "none" + + defp auth_alg(:confidential), do: "ES256" + defp auth_alg(_mode), do: nil + + defp jwks(:confidential, opts) do + jwk_map = Keyword.fetch!(opts, :jwk) + %{"keys" => [public_jwk(jwk_map)]} + end + + defp jwks(_mode, _opts), do: nil end diff --git a/lib/latch/config.ex b/lib/latch/config.ex index 5427ee5..32d1351 100644 --- a/lib/latch/config.ex +++ b/lib/latch/config.ex @@ -4,59 +4,134 @@ defmodule Latch.Config do ## Fields * `:store` - a module implementing `Latch.Store` - * `:client_id` - the URL of the published client metadata document + * `:name` - the name of the Latch instance + * `:mode` - `:confidential`, `:public`, or `:localhost` * `:redirect_uri` - the OAuth callback URL * `:scope` - the requsted scopes + * `:client_id` - the URL of the published client metadata document * `:signing_key` - the ES256 `JOSE.JWK` private key for `private_key_jwt` as string * `:client_name` - shown on the authorization consent screen * `:client_uri` - client home page - * `:name` - the name of the Latch instance """ @default_request_ttl 600 - @enforce_keys [:store, :client_id, :redirect_uri, :scope, :signing_key, :name] - defstruct @enforce_keys ++ [:client_name, :client_uri, request_ttl: @default_request_ttl] + @enforce_keys [:store, :redirect_uri, :scope, :name, :mode] + defstruct @enforce_keys ++ + [ + :client_id, + :signing_key, + :client_name, + :client_uri, + request_ttl: @default_request_ttl + ] + + @type mode :: :confidential | :public | :localhost @type t :: %__MODULE__{ store: module(), - client_id: String.t(), + mode: mode(), + client_id: String.t() | nil, redirect_uri: String.t(), scope: String.t(), - signing_key: String.t(), + signing_key: map() | nil, name: atom() | pid(), client_name: String.t() | nil, client_uri: String.t() | nil, request_ttl: pos_integer() } + @modes [:confidential, :public, :localhost] + @schema [ store: [type: :atom, required: true], - client_id: [type: :string, required: true], + client_id: [type: :string, required: false], redirect_uri: [type: :string, required: true], scope: [type: :string, required: true], - signing_key: [type: :string, required: true], + signing_key: [type: :string, required: false], name: [type: {:or, [:atom, :pid]}, required: true], client_name: [type: :string, required: false], client_uri: [type: :string, required: false], - request_ttl: [type: :pos_integer, required: false, default: @default_request_ttl] + request_ttl: [type: :pos_integer, required: false, default: @default_request_ttl], + mode: [type: {:in, @modes}, required: true] ] @doc false def build!(opts) when is_list(opts) do validated = NimbleOptions.validate!(opts, @schema) + mode = validated[:mode] struct!( __MODULE__, store: validated[:store], - client_id: validated[:client_id], + client_id: client_id!(mode, validated), redirect_uri: validated[:redirect_uri], scope: validated[:scope], - signing_key: Jason.decode!(validated[:signing_key]), + signing_key: signing_key!(mode, validated), name: validated[:name], client_name: validated[:client_name], client_uri: validated[:client_uri], - request_ttl: validated[:request_ttl] + request_ttl: validated[:request_ttl], + mode: mode ) end + + def confidential?(%__MODULE__{mode: :confidential}), do: true + def confidential?(%__MODULE__{}), do: false + + 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 + error( + :client_id, + client_id, + "invalid value for :client_id 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 + else + error( + :client_id, + nil, + "required :client_id option not found, received options: #{inspect(Keyword.keys(validated))}" + ) + end + end + + defp signing_key!(:confidential, validated) do + signing_key = validated[:signing_key] + + if is_binary(signing_key) do + Jason.decode!(signing_key) + else + error( + :signing_key, + nil, + "required :signing_key option not found, received options: #{inspect(Keyword.keys(validated))}" + ) + end + end + + defp signing_key!(mode, validated) do + if key = validated[:signing_key] do + error( + :signing_key, + key, + "invalid value for :signing_key option, not allowed when mode is #{inspect(mode)}" + ) + end + end + + defp error(key, value, message) do + raise %NimbleOptions.ValidationError{key: key, value: value, message: message} + end end diff --git a/lib/latch/discovery.ex b/lib/latch/discovery.ex index 7eb23bc..605ff5e 100644 --- a/lib/latch/discovery.ex +++ b/lib/latch/discovery.ex @@ -19,13 +19,19 @@ defmodule Latch.Discovery do @doc """ Resolves a PDS endpoint to its authorization server metadata. + + ## Options + - `:allow_http` - in localhost mode we don't have to force https """ - @spec discover(String.t()) :: + @spec discover(String.t(), keyword()) :: {:ok, ServerMetadata.t()} | {:error, DiscoveryError.t() | Transport.t()} - def discover(pds_endpoint) when is_binary(pds_endpoint) do - with {:ok, resource} <- HTTP.get_json(pds_endpoint <> @protected_resource_path), - {:ok, issuer} <- authorization_server(resource, pds_endpoint), + def discover(pds_endpoint, opts \\ []) when is_binary(pds_endpoint) do + allow_http = Keyword.get(opts, :allow_http, false) + + with :ok <- require_https(pds_endpoint, allow_http), + {:ok, resource} <- HTTP.get_json(pds_endpoint <> @protected_resource_path), + {:ok, issuer} <- authorization_server(resource, pds_endpoint, allow_http), {:ok, metadata} <- HTTP.get_json(issuer <> @auth_server_path), {:ok, server} <- parse_server_metadata(metadata, pds_endpoint), :ok <- verify_issuer(server, issuer, pds_endpoint) do @@ -33,12 +39,12 @@ defmodule Latch.Discovery do end end - defp authorization_server(resource, pds_endpoint) do + defp authorization_server(resource, pds_endpoint, allow_http) do with :ok <- verify_resource(resource, pds_endpoint) do case Map.get(resource, "authorization_servers") do # There has to be exactly one issuer according to the spec. [issuer] when is_binary(issuer) -> - validate_authorization_server(issuer, pds_endpoint) + validate_authorization_server(issuer, pds_endpoint, allow_http) _ -> {:error, @@ -50,18 +56,19 @@ defmodule Latch.Discovery do end end - defp validate_authorization_server(issuer, pds_endpoint) do + defp validate_authorization_server(issuer, pds_endpoint, allow_http) do case URI.parse(issuer) do %URI{ - scheme: scheme, host: host, path: nil, query: nil, fragment: nil, userinfo: nil } - when scheme in ["http", "https"] and is_binary(host) and host != "" -> - {:ok, issuer} + when is_binary(host) and host != "" -> + with :ok <- require_https(issuer, allow_http) do + {:ok, issuer} + end _ -> {:error, @@ -72,6 +79,14 @@ defmodule Latch.Discovery do end end + defp require_https(url, allow_http) do + case URI.parse(url) do + %URI{scheme: "https"} -> :ok + %URI{scheme: "http"} when allow_http -> :ok + _ -> {:error, %DiscoveryError{reason: :insecure_scheme}} + end + end + defp verify_resource(%{"resource" => resource}, resource), do: :ok defp verify_resource(_resource, pds_endpoint) do diff --git a/lib/latch/error/discovery.ex b/lib/latch/error/discovery.ex index 6a1a1ed..c0de08b 100644 --- a/lib/latch/error/discovery.ex +++ b/lib/latch/error/discovery.ex @@ -9,6 +9,7 @@ defmodule Latch.Error.Discovery do * `:issuer_mismatch` - the AS metadata issuer is not the discovered URL * `{:missing_metadata field}` - a required AS metadata field is missing * `{:invalid_metadata, field}` - a required AS metadata field has invalid value + * `:insecure_scheme` - a PDS or authorization server URL used HTTP instead of HTTPS """ defexception [:pds_endpoint, :reason] diff --git a/lib/latch/flow.ex b/lib/latch/flow.ex index 7474c4f..46889a3 100644 --- a/lib/latch/flow.ex +++ b/lib/latch/flow.ex @@ -27,7 +27,7 @@ defmodule Latch.Flow do Performs a pushed authorization request, returning the `request_uri`. ## Required options - - `:client_id`, `:client_jwk` - the confidential client's id and signing key + - `:client_id`, the client's id - `:redirect_uri`, `:scope`, `:state`, `:code_challenge` - auth params - `:dpop_key` - the per-session DPoP key @@ -40,7 +40,6 @@ defmodule Latch.Flow do | {:error, InvalidResponse.t() | MissingDPoPNonce.t() | OAuth.t() | Transport.t()} def par(%Config{} = config, %ServerMetadata{} = server, opts) do client_id = Keyword.fetch!(opts, :client_id) - client_jwk = Keyword.fetch!(opts, :client_jwk) redirect_uri = Keyword.fetch!(opts, :redirect_uri) scope = Keyword.fetch!(opts, :scope) state = Keyword.fetch!(opts, :state) @@ -57,10 +56,8 @@ defmodule Latch.Flow do scope: scope, state: state, code_challenge: code_challenge, - code_challenge_method: "S256", - client_assertion_type: ClientAssertion.assertion_type(), - client_assertion: ClientAssertion.sign(client_jwk, client_id, server.issuer) - ], + code_challenge_method: "S256" + ] ++ client_assertion(config, client_id, server.issuer), :login_hint, login_hint ) @@ -112,7 +109,6 @@ defmodule Latch.Flow do | Transport.t()} def exchange_code(%Config{} = config, opts) do client_id = Keyword.fetch!(opts, :client_id) - client_jwk = Keyword.fetch!(opts, :client_jwk) redirect_uri = Keyword.fetch!(opts, :redirect_uri) code = Keyword.fetch!(opts, :code) code_verifier = Keyword.fetch!(opts, :code_verifier) @@ -129,10 +125,8 @@ defmodule Latch.Flow do code: code, redirect_uri: redirect_uri, code_verifier: code_verifier, - client_id: client_id, - client_assertion_type: ClientAssertion.assertion_type(), - client_assertion: ClientAssertion.sign(client_jwk, client_id, issuer) - ] + client_id: client_id + ] ++ client_assertion(config, client_id, issuer) end with {:ok, body} <- dpop_request(config, token_endpoint, build_form, dpop_key), @@ -166,17 +160,14 @@ defmodule Latch.Flow do | Transport.t()} def refresh(config, %ServerMetadata{} = server, %Session{} = session, opts) do client_id = Keyword.fetch!(opts, :client_id) - client_jwk = Keyword.fetch!(opts, :client_jwk) now = Keyword.get_lazy(opts, :now, &DateTime.utc_now/0) build_form = fn -> [ grant_type: "refresh_token", refresh_token: session.refresh_token, - client_id: client_id, - client_assertion_type: ClientAssertion.assertion_type(), - client_assertion: ClientAssertion.sign(client_jwk, client_id, server.issuer) - ] + client_id: client_id + ] ++ client_assertion(config, client_id, server.issuer) end with :ok <- verify_refresh_issuer(server.issuer, session.issuer), @@ -319,4 +310,15 @@ defmodule Latch.Flow do expires_at: DateTime.add(now, tokens.expires_in, :second) } end + + defp client_assertion(config, client_id, issuer) do + if Config.confidential?(config) do + [ + client_assertion_type: ClientAssertion.assertion_type(), + client_assertion: ClientAssertion.sign(config.signing_key, client_id, issuer) + ] + else + [] + end + end end diff --git a/test/latch/client_test.exs b/test/latch/client_test.exs index 902aa8b..079e068 100644 --- a/test/latch/client_test.exs +++ b/test/latch/client_test.exs @@ -26,7 +26,7 @@ defmodule Latch.ClientTest do :ok = Latch.TestStore.put_session(@did, stale_session) - expect(Discovery, :discover, fn @pds -> {:ok, server} end) + 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 @@ -63,7 +63,7 @@ defmodule Latch.ClientTest do {:ok, %{"did" => @did}} end) - expect(Discovery, :discover, fn @pds -> {:ok, server} end) + expect(Discovery, :discover, fn @pds, _opts -> {:ok, server} end) expect(Flow, :refresh, fn _config, ^server, ^stale_session, _opts -> {:ok, refreshed_session} @@ -138,7 +138,8 @@ defmodule Latch.ClientTest do redirect_uri: @redirect_uri, scope: "atproto", signing_key: ~s({"kty":"EC"}), - name: :name + name: :name, + mode: :confidential } end end diff --git a/test/latch/config_test.exs b/test/latch/config_test.exs index 01c22db..6b4fbcd 100644 --- a/test/latch/config_test.exs +++ b/test/latch/config_test.exs @@ -38,9 +38,48 @@ defmodule Latch.ConfigTest do Config.build!(opts(doesntexist: "string")) end end + + test "localhost mode rejects :client_id" do + assert_raise NimbleOptions.ValidationError, + fn -> + [mode: :localhost, client_id: "https://prod.example/metadata.json"] + |> opts() + |> Keyword.delete(:signing_key) + |> Config.build!() + end + end + + test "confidential mode requires :signing_key" do + assert_raise NimbleOptions.ValidationError, + fn -> + opts() + |> Keyword.delete(:signing_key) + |> Config.build!() + end + end + + test "public mode rejects :signing_key" do + assert_raise NimbleOptions.ValidationError, + fn -> + [mode: :public] + |> opts() + |> Config.build!() + end + end + + test "localhost mode rejects :signing_key" do + assert_raise NimbleOptions.ValidationError, + ~r/invalid value for :signing_key option, not allowed when mode is :localhost/, + fn -> + [mode: :localhost] + |> opts() + |> Keyword.delete(:client_id) + |> Config.build!() + end + end end - defp opts(overrides) do + defp opts(overrides \\ []) do Keyword.merge( [ store: @store, @@ -50,7 +89,8 @@ defmodule Latch.ConfigTest do signing_key: @signing_key, name: @name, client_name: @client_name, - client_uri: @client_uri + client_uri: @client_uri, + mode: :confidential ], overrides ) diff --git a/test/latch/flow_test.exs b/test/latch/flow_test.exs index dcc2062..6631967 100644 --- a/test/latch/flow_test.exs +++ b/test/latch/flow_test.exs @@ -14,15 +14,7 @@ defmodule Latch.FlowTest do did = "did:plc:bvraa6gajy4tfr3eh2sisdkr" access_token = "access-token" refresh_token = "refresh-token" - - config = %Latch.Config{ - store: Latch.TestStore, - client_id: "https://client.example.com/oauth-client-metadata.json", - redirect_uri: "https://client.example.com/oauth/callback", - scope: "atproto", - signing_key: ~s({"kty":"EC"}), - name: :"flow_test_#{inspect(self())}" - } + config = make_config() start_link_supervised!( {Latch.NonceCache, config: config, name: config.name, sweep_disabled: true} @@ -81,29 +73,14 @@ defmodule Latch.FlowTest do describe "par/2" do test "creates a pushed authorization request" do - client_jwk = DPoP.generate_key() dpop_key = DPoP.generate_key() - - config = %Latch.Config{ - store: Latch.TestStore, - client_id: "https://client.example.com/oauth-client-metadata.json", - redirect_uri: "https://client.example.com/oauth/callback", - scope: "atproto", - signing_key: ~s({"kty":"EC"}), - name: :"flow_test_#{inspect(self())}" - } + config = make_config() start_link_supervised!( {Latch.NonceCache, config: config, name: config.name, sweep_disabled: true} ) - server = %ServerMetadata{ - issuer: "https://issuer.example.com", - authorization_endpoint: "https://issuer.example.com/oauth/authorize", - token_endpoint: "https://issuer.example.com/oauth/token", - par_endpoint: "https://issuer.example.com/oauth/par", - scopes_supported: ["atproto"] - } + server = make_server_metadata() expect(HTTP, :post_form, fn url, form, headers -> assert url == "https://issuer.example.com/oauth/par" @@ -124,7 +101,6 @@ defmodule Latch.FlowTest do assert {:ok, "urn:ietf:params:oauth:request_uri:request"} = Flow.par(config, server, client_id: "https://client.example.com/oauth-client-metadata.json", - client_jwk: client_jwk, redirect_uri: "https://client.example.com/oauth/callback", scope: "atproto", state: "state", @@ -133,20 +109,88 @@ defmodule Latch.FlowTest do login_hint: "alice.example.com" ) end + + test "public client omits client_assertion" do + dpop_key = DPoP.generate_key() + config = make_config(mode: :public) + + start_link_supervised!( + {Latch.NonceCache, config: config, name: config.name, sweep_disabled: true} + ) + + server = make_server_metadata() + + expect(HTTP, :post_form, fn url, form, _headers -> + assert url == server.par_endpoint + refute Keyword.has_key?(form, :client_assertion) + refute Keyword.has_key?(form, :client_assertion_type) + + {:ok, + %{ + status: 201, + headers: %{}, + body: ~s({"request_uri":"urn:ietf:params:oauth:request_uri:request"}) + }} + end) + + assert {:ok, "urn:ietf:params:oauth:request_uri:request"} = + Flow.par(config, server, + client_id: "https://client.example.com/oauth-client-metadata.json", + redirect_uri: "https://client.example.com/oauth/callback", + scope: "atproto", + state: "state", + code_challenge: "pkce-challenge", + dpop_key: dpop_key + ) + end + + 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" + ) + + start_link_supervised!( + {Latch.NonceCache, config: config, name: config.name, sweep_disabled: true} + ) + + server = make_server_metadata() + + expect(HTTP, :post_form, fn url, form, _headers -> + assert url == server.par_endpoint + refute Keyword.has_key?(form, :client_assertion) + refute Keyword.has_key?(form, :client_assertion_type) + + {:ok, + %{ + status: 201, + headers: %{}, + body: ~s({"request_uri":"urn:ietf:params:oauth:request_uri:request"}) + }} + end) + + assert {:ok, "urn:ietf:params:oauth:request_uri:request"} = + Flow.par(config, server, + client_id: "https://client.example.com/oauth-client-metadata.json", + redirect_uri: "https://client.example.com/oauth/callback", + scope: "atproto", + state: "state", + code_challenge: "pkce-challenge", + dpop_key: dpop_key + ) + end end describe "refresh/3" do test "rejects a refresh when discovery returns a different issuer" do reject(HTTP, :post_form, 3) - - config = %Latch.Config{ - store: Latch.TestStore, - client_id: "https://client.example.com/oauth-client-metadata.json", - redirect_uri: "https://client.example.com/oauth/callback", - scope: "atproto", - signing_key: ~s({"kty":"EC"}), - name: :"flow_test_#{inspect(self())}" - } + config = make_config() start_link_supervised!( {Latch.NonceCache, config: config, name: config.name, sweep_disabled: true} @@ -178,4 +222,29 @@ defmodule Latch.FlowTest do ) end end + + 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", + scope: "atproto", + signing_key: Jason.decode!(Jason.encode!(Latch.DPoP.generate_key())), + name: :"flow_test_#{inspect(self())}", + mode: :confidential + ] + + attrs = Keyword.merge(defaults, overrides) + struct!(Latch.Config, attrs) + end + + defp make_server_metadata do + %ServerMetadata{ + issuer: "https://issuer.example.com", + authorization_endpoint: "https://issuer.example.com/oauth/authorize", + token_endpoint: "https://issuer.example.com/oauth/token", + par_endpoint: "https://issuer.example.com/oauth/par", + scopes_supported: ["atproto"] + } + end end diff --git a/test/latch/nonce_cache_test.exs b/test/latch/nonce_cache_test.exs index 749cd0b..37b5274 100644 --- a/test/latch/nonce_cache_test.exs +++ b/test/latch/nonce_cache_test.exs @@ -81,7 +81,8 @@ defmodule Latch.NonceCacheTest do redirect_uri: "redirect-uri", scope: "atproto", signing_key: ~s({"kty":"EC"}), - name: name + name: name, + mode: :confidential } end end diff --git a/test/latch_test.exs b/test/latch_test.exs index e2167ca..6c340b1 100644 --- a/test/latch_test.exs +++ b/test/latch_test.exs @@ -29,14 +29,15 @@ defmodule LatchTest do client_id: @client_id, redirect_uri: @redirect_uri, scope: "atproto", - signing_key: ~s({"kty":"EC"}) + signing_key: Jason.encode!(Latch.DPoP.generate_key()), + mode: :confidential ) identity = %Identity{did: @did, handle: @handle, pds_endpoint: @pds} server = server() expect(Identity, :resolve_handle, fn @handle -> {:ok, identity} end) - expect(Discovery, :discover, fn @pds -> {:ok, server} end) + expect(Discovery, :discover, fn @pds, _opts -> {:ok, server} end) expect(Flow, :par, fn config, ^server, opts -> assert opts[:client_id] == config.client_id @@ -89,7 +90,8 @@ defmodule LatchTest do client_id: @client_id, redirect_uri: @redirect_uri, scope: "atproto", - signing_key: ~s({"kty":"EC"}) + signing_key: Jason.encode!(Latch.DPoP.generate_key()), + mode: :confidential ) request = %Request{ @@ -150,7 +152,8 @@ defmodule LatchTest do client_id: @client_id, redirect_uri: @redirect_uri, scope: "atproto", - signing_key: ~s({"kty":"EC"}) + signing_key: Jason.encode!(Latch.DPoP.generate_key()), + mode: :confidential ) expect(Client, :query, fn _config, @did, "app.bsky.actor.getProfile", actor: @did -> @@ -170,7 +173,8 @@ defmodule LatchTest do client_id: @client_id, redirect_uri: @redirect_uri, scope: "atproto", - signing_key: ~s({"kty":"EC"}) + signing_key: Jason.encode!(Latch.DPoP.generate_key()), + mode: :confidential ) body = %{ @@ -196,7 +200,8 @@ defmodule LatchTest do client_id: @client_id, redirect_uri: @redirect_uri, scope: "atproto", - signing_key: ~s({"kty":"EC"}) + signing_key: Jason.encode!(Latch.DPoP.generate_key()), + mode: :confidential ) expect(Client, :upload_blob, fn _config, @did, <<1, 2, 3>>, "image/png" -> @@ -207,6 +212,87 @@ defmodule LatchTest do end end + describe "client_metadata/1" do + test "confidential" do + pid = + start_latch( + store: Latch.TestStore, + client_id: @client_id, + redirect_uri: @redirect_uri, + scope: "atproto", + signing_key: Jason.encode!(Latch.DPoP.generate_key()), + mode: :confidential + ) + + assert %{ + "application_type" => "web", + "client_id" => "https://client.example.com/oauth-client-metadata.json", + "dpop_bound_access_tokens" => true, + "grant_types" => ["authorization_code", "refresh_token"], + "jwks" => %{ + "keys" => [ + %{ + "crv" => _, + "kid" => _, + "kty" => _, + "x" => _, + "y" => _ + } + ] + }, + "redirect_uris" => ["https://client.example.com/oauth/callback"], + "response_types" => ["code"], + "scope" => "atproto", + "token_endpoint_auth_method" => "private_key_jwt", + "token_endpoint_auth_signing_alg" => "ES256" + } = Latch.client_metadata(pid) + end + + test "public" do + pid = + start_latch( + store: Latch.TestStore, + client_id: @client_id, + redirect_uri: @redirect_uri, + scope: "atproto", + mode: :public + ) + + assert %{ + "application_type" => "web", + "client_id" => "https://client.example.com/oauth-client-metadata.json", + "dpop_bound_access_tokens" => true, + "grant_types" => ["authorization_code", "refresh_token"], + "redirect_uris" => ["https://client.example.com/oauth/callback"], + "response_types" => ["code"], + "scope" => "atproto", + "token_endpoint_auth_method" => "none" + } = Latch.client_metadata(pid) + end + + test "localhost" do + pid = + start_latch( + store: Latch.TestStore, + redirect_uri: @redirect_uri, + scope: "atproto", + mode: :localhost + ) + + assert %{ + "application_type" => "native", + "client_id" => + "http://localhost?redirect_uri=https%3A%2F%2Fclient.example.com%2Foauth%2Fcallback&scope=atproto", + "dpop_bound_access_tokens" => true, + "grant_types" => ["authorization_code", "refresh_token"], + "redirect_uris" => ["https://client.example.com/oauth/callback"], + "response_types" => ["code"], + "scope" => "atproto", + "token_endpoint_auth_method" => "none" + } = Latch.client_metadata(pid) + end + end + defp server do %ServerMetadata{ issuer: @issuer,