···11+defmodule AnnotAt.Atproto.OAuth.ServerMetadata do
22+ @moduledoc """
33+ Parses authorization server metadata (`/.well-known/oauth-authorization-server`)
44+ per the atproto OAuth profile.
55+66+ Validates the document's internal correctness only. The caller must also
77+ verify that the `issuer` matches the origin the document was fetched from.
88+ """
99+1010+ @enforce_keys [
1111+ :issuer,
1212+ :authorization_endpoint,
1313+ :token_endpoint,
1414+ :par_endpoint,
1515+ :scopes_supported
1616+ ]
1717+ defstruct @enforce_keys ++ [:revocation_endpoint]
1818+1919+ @type t :: %__MODULE__{
2020+ issuer: String.t(),
2121+ authorization_endpoint: String.t(),
2222+ token_endpoint: String.t(),
2323+ par_endpoint: String.t(),
2424+ revocation_endpoint: String.t() | nil,
2525+ scopes_supported: [String.t()]
2626+ }
2727+2828+ @doc """
2929+ Validates a decoded metadata document and extracts the fields we use.
3030+3131+ Checks every requirement the atproto OAuth spec places on authorization
3232+ servers, except `none` in `token_endpoint_auth_methods_supported`, which
3333+ only matters to public clients.
3434+3535+ Uses a manual validation layer instead of Ecto.Changeset because I want to
3636+ split this out eventually.
3737+ """
3838+ @spec parse(map()) :: {:ok, t()} | {:error, {:missing | :invalid, String.t()}}
3939+ def parse(metadata) when is_map(metadata) do
4040+ with :ok <- origin_url(metadata, "issuer"),
4141+ :ok <- https_url(metadata, "authorization_endpoint"),
4242+ :ok <- https_url(metadata, "token_endpoint"),
4343+ :ok <- https_url(metadata, "pushed_authorization_request_endpoint"),
4444+ :ok <- member(metadata, "response_types_supported", "code"),
4545+ :ok <- member(metadata, "grant_types_supported", "authorization_code"),
4646+ :ok <- member(metadata, "grant_types_supported", "refresh_token"),
4747+ :ok <- member(metadata, "code_challenge_methods_supported", "S256"),
4848+ :ok <- member(metadata, "token_endpoint_auth_methods_supported", "private_key_jwt"),
4949+ :ok <- member(metadata, "token_endpoint_auth_signing_alg_values_supported", "ES256"),
5050+ :ok <- member(metadata, "scopes_supported", "atproto"),
5151+ :ok <- member(metadata, "dpop_signing_alg_values_supported", "ES256"),
5252+ :ok <- flag(metadata, "require_pushed_authorization_requests"),
5353+ :ok <- flag(metadata, "authorization_response_iss_parameter_supported"),
5454+ :ok <- flag(metadata, "client_id_metadata_document_supported") do
5555+ {:ok,
5656+ %__MODULE__{
5757+ issuer: Map.fetch!(metadata, "issuer"),
5858+ authorization_endpoint: Map.fetch!(metadata, "authorization_endpoint"),
5959+ token_endpoint: Map.fetch!(metadata, "token_endpoint"),
6060+ par_endpoint: Map.fetch!(metadata, "pushed_authorization_request_endpoint"),
6161+ revocation_endpoint: Map.get(metadata, "revocation_endpoint"),
6262+ scopes_supported: Map.fetch!(metadata, "scopes_supported")
6363+ }}
6464+ end
6565+ end
6666+6767+ defp origin_url(metadata, field) do
6868+ with :ok <- https_url(metadata, field) do
6969+ url = Map.get(metadata, field)
7070+7171+ case URI.parse(url) do
7272+ %URI{path: nil, query: nil, fragment: nil, userinfo: nil} ->
7373+ :ok
7474+7575+ _ ->
7676+ {:error, {:invalid, field}}
7777+ end
7878+ end
7979+ end
8080+8181+ defp https_url(metadata, field) do
8282+ case Map.get(metadata, field) do
8383+ nil ->
8484+ {:error, {:missing, field}}
8585+8686+ value when is_binary(value) ->
8787+ case URI.parse(value) do
8888+ %URI{scheme: "https", host: host} when is_binary(host) and host != "" -> :ok
8989+ _ -> {:error, {:invalid, field}}
9090+ end
9191+9292+ _ ->
9393+ {:error, {:invalid, field}}
9494+ end
9595+ end
9696+9797+ defp member(metadata, field, value) do
9898+ case Map.get(metadata, field) do
9999+ nil ->
100100+ {:error, {:missing, field}}
101101+102102+ list when is_list(list) ->
103103+ if value in list do
104104+ :ok
105105+ else
106106+ {:error, {:invalid, field}}
107107+ end
108108+109109+ _ ->
110110+ {:error, {:invalid, field}}
111111+ end
112112+ end
113113+114114+ defp flag(metadata, field) do
115115+ case Map.get(metadata, field) do
116116+ true -> :ok
117117+ nil -> {:error, {:missing, field}}
118118+ _ -> {:error, {:invalid, field}}
119119+ end
120120+ end
121121+end
+99
lib/annot_at/atproto/oauth/token_response.ex
···11+defmodule AnnotAt.Atproto.OAuth.TokenResponse do
22+ @moduledoc """
33+ Parses token endpoint responses per the atproto OAuth profile.
44+ """
55+66+ @enforce_keys [
77+ :access_token,
88+ :refresh_token,
99+ :expires_in,
1010+ :scope,
1111+ :sub
1212+ ]
1313+ defstruct @enforce_keys
1414+1515+ @type t :: %__MODULE__{
1616+ access_token: String.t(),
1717+ refresh_token: String.t(),
1818+ expires_in: pos_integer(),
1919+ scope: String.t(),
2020+ sub: String.t()
2121+ }
2222+2323+ @doc """
2424+ Validates a decoded token response and extracts the fields we use.
2525+2626+ `refresh_token` and `expires_in` are optional in RFC 6749 but required
2727+ here: background polling depends on refresh, and refresh scheduling
2828+ depends on expiry, so a server omitting either fails at login instead
2929+ of failing later. `token_type` must be `DPoP` (compared case-insensitively
3030+ per RFC 6749) and carries no information once validated, so it is not
3131+ kept on the struct.
3232+3333+ `sub` is only checked syntactically. The caller must verify it equals
3434+ the DID resolved from the user's handle before trusting it.
3535+ """
3636+ @spec parse(map()) :: {:ok, t()} | {:error, {:missing | :invalid, String.t()}}
3737+ def parse(response) when is_map(response) do
3838+ with :ok <- string(response, "access_token"),
3939+ :ok <- string(response, "refresh_token"),
4040+ :ok <- string(response, "scope"),
4141+ :ok <- token_type(response),
4242+ :ok <- expires_in(response),
4343+ :ok <- did(response, "sub") do
4444+ {:ok,
4545+ %__MODULE__{
4646+ access_token: Map.fetch!(response, "access_token"),
4747+ refresh_token: Map.fetch!(response, "refresh_token"),
4848+ expires_in: Map.fetch!(response, "expires_in"),
4949+ scope: Map.fetch!(response, "scope"),
5050+ sub: Map.fetch!(response, "sub")
5151+ }}
5252+ end
5353+ end
5454+5555+ defp string(response, field) do
5656+ case Map.get(response, field) do
5757+ nil -> {:error, {:missing, field}}
5858+ value when is_binary(value) and value != "" -> :ok
5959+ _ -> {:error, {:invalid, field}}
6060+ end
6161+ end
6262+6363+ defp token_type(response) do
6464+ case Map.get(response, "token_type") do
6565+ nil ->
6666+ {:error, {:missing, "token_type"}}
6767+6868+ value when is_binary(value) ->
6969+ if String.downcase(value) == "dpop" do
7070+ :ok
7171+ else
7272+ {:error, {:invalid, "token_type"}}
7373+ end
7474+7575+ _ ->
7676+ {:error, {:invalid, "token_type"}}
7777+ end
7878+ end
7979+8080+ defp expires_in(response) do
8181+ case Map.get(response, "expires_in") do
8282+ nil -> {:error, {:missing, "expires_in"}}
8383+ value when is_integer(value) and value > 0 -> :ok
8484+ _ -> {:error, {:invalid, "expires_in"}}
8585+ end
8686+ end
8787+8888+ defp did(response, field) do
8989+ with :ok <- string(response, field) do
9090+ did = Map.fetch!(response, field)
9191+9292+ if String.starts_with?(did, "did:") do
9393+ :ok
9494+ else
9595+ {:error, {:invalid, field}}
9696+ end
9797+ end
9898+ end
9999+end