A set of utilities for working with the AT Protocol in Elixir.

feat: basic OAuth Plug and utils

ovyerus.com 1164a1e3 81de840e

verified
Changed files
+736 -19
config
lib
atex
config
identity_resolver
oauth
xrpc
mix
+1 -1
.formatter.exs
···
# Used by "mix format"
[
inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"],
-
import_deps: [:typedstruct, :peri],
+
import_deps: [:typedstruct, :peri, :plug],
export: [
locals_without_parens: [deflexicon: 1]
]
+5 -1
.gitignore
···
.vscode/
.elixir_ls
lexicons
-
lib/atproto
+
lib/atproto
+
secrets
+
node_modules
+
atproto-oauth-example
+
.DS_Store
+13 -1
CHANGELOG.md
···
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-
<!-- ## [Unreleased] -->
+
## [Unreleased]
+
+
### Breaking Changes
+
+
- Remove `Atex.HTTP` and associated modules as the abstraction caused a bit too
+
much complexities for how early atex is. It may come back in the future as
+
something more fleshed out once we're more stable.
+
+
### Features
+
+
- Add `Atex.OAuth` module with utilites for handling some OAuth functionality.
+
- Add `Atex.OAuth.Plug` module (if Plug is loaded) which provides a basic but
+
complete OAuth flow, including storing the tokens in `Plug.Session`.
## [0.4.0] - 2025-08-27
+10
config/runtime.exs
···
+
import Config
+
+
config :atex, Atex.OAuth,
+
# base_url: "https://comet.sh/aaaa",
+
base_url: "http://127.0.0.1:4000/oauth",
+
is_localhost: true,
+
scopes: ~w(transition:generic),
+
private_key:
+
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgyIpxhuDm0i3mPkrk6UdX4Sd9Jsv6YtAmSTza+A2nArShRANCAAQLF1GLueOBZOVnKWfrcnoDOO9NSRqH2utmfGMz+Rce18MDB7Z6CwFWjEq2UFYNBI4MI5cMI0+m+UYAmj4OZm+m",
+
key_id: "awooga"
+86
lib/atex/config/oauth.ex
···
+
defmodule Atex.Config.OAuth do
+
@moduledoc """
+
Configuration management for `Atex.OAuth`.
+
+
Contains all the logic for fetching configuration needed for the OAuth
+
module, as well as deriving useful values from them.
+
+
## Configuration
+
+
The following structure is expected in your application config:
+
+
config :atex, Atex.OAuth,
+
base_url: "https://example.com/oauth", # Your application's base URL, including the path `Atex.OAuth` is mounted on.
+
private_key: "base64-encoded-private-key", # ES256 private key
+
key_id: "your-key-id", # Key identifier for JWTs
+
scopes: ["transition:generic", "transition:email"], # Optional additional scopes
+
extra_redirect_uris: ["https://alternative.com/callback"], # Optional additional redirect URIs
+
is_localhost: false # Set to true for local development
+
"""
+
+
@doc """
+
Returns the configured public base URL for OAuth routes.
+
"""
+
@spec base_url() :: String.t()
+
def base_url, do: Application.fetch_env!(:atex, Atex.OAuth)[:base_url]
+
+
@doc """
+
Returns the configured private key as a `JOSE.JWK`.
+
"""
+
@spec get_key() :: JOSE.JWK.t()
+
def get_key() do
+
private_key =
+
Application.fetch_env!(:atex, Atex.OAuth)[:private_key]
+
|> Base.decode64!()
+
|> JOSE.JWK.from_der()
+
+
key_id = Application.fetch_env!(:atex, Atex.OAuth)[:key_id]
+
+
%{private_key | fields: %{"kid" => key_id}}
+
end
+
+
@doc """
+
Returns the client ID based on configuration.
+
+
If `is_localhost` is set, it'll be a string handling the "http://localhost"
+
special case, with the redirect URI and scopes configured, otherwise it is a
+
string pointing to the location of the `client-metadata.json` route.
+
"""
+
@spec client_id() :: String.t()
+
def client_id() do
+
is_localhost = Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :is_localhost, false)
+
+
if is_localhost do
+
query =
+
%{redirect_uri: redirect_uri(), scope: scopes()}
+
|> URI.encode_query()
+
+
"http://localhost?#{query}"
+
else
+
"#{base_url()}/client-metadata.json"
+
end
+
end
+
+
@doc """
+
Returns the configured redirect URI.
+
"""
+
@spec redirect_uri() :: String.t()
+
def redirect_uri(), do: "#{base_url()}/callback"
+
+
@doc """
+
Returns the configured scopes joined as a space-separated string.
+
"""
+
@spec scopes() :: String.t()
+
def scopes() do
+
config_scopes = Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :scopes, [])
+
Enum.join(["atproto" | config_scopes], " ")
+
end
+
+
@doc """
+
Returns the configured extra redirect URIs.
+
"""
+
@spec extra_redirect_uris() :: [String.t()]
+
def extra_redirect_uris() do
+
Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :extra_redirect_uris, [])
+
end
+
end
+13
lib/atex/identity_resolver/did_document.ex
···
end)
end
+
@spec get_pds_endpoint(t()) :: String.t() | nil
+
def get_pds_endpoint(%__MODULE__{} = doc) do
+
doc.service
+
|> Enum.find(fn
+
%{id: "#atproto_pds", type: "AtprotoPersonalDataServer"} -> true
+
_ -> false
+
end)
+
|> case do
+
nil -> nil
+
pds -> pds.service_endpoint
+
end
+
end
+
defp valid_pds_endpoint?(endpoint) do
case URI.new(endpoint) do
{:ok, uri} ->
+430
lib/atex/oauth.ex
···
+
defmodule Atex.OAuth do
+
@moduledoc """
+
OAuth 2.0 implementation for AT Protocol authentication.
+
+
This module provides utilities for implementing OAuth flows compliant with the
+
AT Protocol specification. It includes support for:
+
+
- Pushed Authorization Requests (PAR)
+
- DPoP (Demonstration of Proof of Possession) tokens
+
- JWT client assertions
+
- PKCE (Proof Key for Code Exchange)
+
- Token refresh
+
- Handle to PDS resolution
+
+
## Configuration
+
+
See `Atex.Config.OAuth` module for configuration documentation.
+
+
## Usage Example
+
+
iex> pds = "https://bsky.social"
+
iex> login_hint = "example.com"
+
iex> {:ok, authz_server} = Atex.OAuth.get_authorization_server(pds)
+
iex> {:ok, authz_metadata} = Atex.OAuth.get_authorization_server_metadata(authz_server)
+
iex> state = Atex.OAuth.create_nonce()
+
iex> code_verifier = Atex.OAuth.create_nonce()
+
iex> {:ok, auth_url} = Atex.OAuth.create_authorization_url(
+
authz_metadata,
+
state,
+
code_verifier,
+
login_hint
+
)
+
"""
+
+
@type authorization_metadata() :: %{
+
issuer: String.t(),
+
par_endpoint: String.t(),
+
token_endpoint: String.t(),
+
authorization_endpoint: String.t()
+
}
+
+
@type tokens() :: %{
+
access_token: String.t(),
+
refresh_token: String.t(),
+
did: String.t(),
+
expires_at: NaiveDateTime.t()
+
}
+
+
alias Atex.Config.OAuth, as: Config
+
+
@doc """
+
Get a map cnotaining the client metadata information needed for an
+
authorization server to validate this client.
+
"""
+
@spec create_client_metadata() :: map()
+
def create_client_metadata() do
+
key = Config.get_key()
+
{_, jwk} = key |> JOSE.JWK.to_public_map()
+
jwk = Map.merge(jwk, %{use: "sig", kid: key.fields["kid"]})
+
+
# TODO: read more about client-metadata and what specific fields mean to see that we're doing what we actually want to be doing
+
+
%{
+
client_id: Config.client_id(),
+
redirect_uris: [Config.redirect_uri() | Config.extra_redirect_uris()],
+
application_type: "web",
+
grant_types: ["authorization_code", "refresh_token"],
+
scope: Config.scopes(),
+
response_type: ["code"],
+
token_endpoint_auth_method: "private_key_jwt",
+
token_endpoint_auth_signing_alg: "ES256",
+
dpop_bound_access_tokens: true,
+
jwks: %{keys: [jwk]}
+
}
+
end
+
+
@doc """
+
Retrieves the configured JWT private key for signing client assertions.
+
+
Loads the private key from configuration, decodes the base64-encoded DER data,
+
and creates a JOSE JWK structure with the key ID field set.
+
+
## Returns
+
+
A `JOSE.JWK` struct containing the private key and key identifier.
+
+
## Raises
+
+
* `Application.Env.Error` if the private_key or key_id configuration is missing
+
+
## Examples
+
+
key = OAuth.get_key()
+
key = OAuth.get_key()
+
"""
+
@spec get_key() :: JOSE.JWK.t()
+
def get_key(), do: Config.get_key()
+
+
@doc false
+
@spec random_b64(integer()) :: String.t()
+
def random_b64(length) do
+
:crypto.strong_rand_bytes(length)
+
|> Base.url_encode64(padding: false)
+
end
+
+
@doc false
+
@spec create_nonce() :: String.t()
+
def create_nonce(), do: random_b64(32)
+
+
@doc """
+
Create an OAuth authorization URL for a PDS.
+
+
Submits a PAR request to the authorization server and constructs the
+
authorization URL with the returned request URI. Supports PKCE, DPoP, and
+
client assertions as required by the AT Protocol.
+
+
## Parameters
+
+
- `authz_metadata` - Authorization server metadata containing endpoints, fetched from `get_authorization_server_metadata/1`
+
- `state` - Random token for session validation
+
- `code_verifier` - PKCE code verifier
+
- `login_hint` - User identifier (handle or DID) for pre-filled login
+
+
## Returns
+
+
- `{:ok, authorization_url}` - Successfully created authorization URL
+
- `{:ok, :invalid_par_response}` - Server respondend incorrectly to the request
+
- `{:error, reason}` - Error creating authorization URL
+
"""
+
@spec create_authorization_url(
+
authorization_metadata(),
+
String.t(),
+
String.t(),
+
String.t()
+
) :: {:ok, String.t()} | {:error, any()}
+
def create_authorization_url(
+
authz_metadata,
+
state,
+
code_verifier,
+
login_hint
+
) do
+
code_challenge = :crypto.hash(:sha256, code_verifier) |> Base.url_encode64(padding: false)
+
key = get_key()
+
+
# TODO: let keys be optional so no client assertion? is this what results in a confidential client??
+
client_assertion =
+
create_client_assertion(key, Config.client_id(), authz_metadata.issuer)
+
+
body =
+
%{
+
response_type: "code",
+
client_id: Config.client_id(),
+
redirect_uri: Config.redirect_uri(),
+
state: state,
+
code_challenge_method: "S256",
+
code_challenge: code_challenge,
+
scope: Config.scopes(),
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
+
client_assertion: client_assertion,
+
login_hint: login_hint
+
}
+
+
case Req.post(authz_metadata.par_endpoint, form: body) do
+
{:ok, %{body: %{"request_uri" => request_uri}}} ->
+
query =
+
%{client_id: Config.client_id(), request_uri: request_uri}
+
|> URI.encode_query()
+
+
{:ok, "#{authz_metadata.authorization_endpoint}?#{query}"}
+
+
{:ok, _} ->
+
{:error, :invalid_par_response}
+
+
err ->
+
err
+
end
+
end
+
+
@doc """
+
Exchange an OAuth authorization code for a set of access and refresh tokens.
+
+
Validates the authorization code by submitting it to the token endpoint along with
+
the PKCE code verifier and client assertion. Returns access tokens for making authenticated
+
requests to the relevant user's PDS.
+
+
## Parameters
+
+
- `authz_metadata` - Authorization server metadata containing token endpoint
+
- `dpop_key` - JWK for DPoP token generation
+
- `code` - Authorization code from OAuth callback
+
- `code_verifier` - PKCE code verifier from authorization flow
+
+
## Returns
+
+
- `{:ok, tokens, nonce}` - Successfully obtained tokens with returned DPoP nonce
+
- `{:error, reason}` - Error exchanging code for tokens
+
"""
+
@spec validate_authorization_code(
+
authorization_metadata(),
+
JOSE.JWK.t(),
+
String.t(),
+
String.t()
+
) :: {:ok, tokens(), String.t()} | {:error, any()}
+
def validate_authorization_code(
+
authz_metadata,
+
dpop_key,
+
code,
+
code_verifier
+
) do
+
key = get_key()
+
+
client_assertion =
+
create_client_assertion(key, Config.client_id(), authz_metadata.issuer)
+
+
body =
+
%{
+
grant_type: "authorization_code",
+
client_id: Config.client_id(),
+
redirect_uri: Config.redirect_uri(),
+
code: code,
+
code_verifier: code_verifier,
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
+
client_assertion: client_assertion
+
}
+
+
Req.new(method: :post, url: authz_metadata.token_endpoint, form: body)
+
|> send_dpop_request(dpop_key)
+
|> case do
+
{:ok,
+
%{
+
"access_token" => access_token,
+
"refresh_token" => refresh_token,
+
"expires_in" => expires_in,
+
"sub" => did
+
}, nonce} ->
+
expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second)
+
+
{:ok,
+
%{
+
access_token: access_token,
+
refresh_token: refresh_token,
+
did: did,
+
expires_at: expires_at
+
}, nonce}
+
+
err ->
+
err
+
end
+
end
+
+
@doc """
+
Fetch the authorization server for a given Personal Data Server (PDS).
+
+
Makes a request to the PDS's `.well-known/oauth-protected-resource` endpoint
+
to discover the associated authorization server that should be used for the
+
OAuth flow.
+
+
## Parameters
+
+
- `pds_host` - Base URL of the PDS (e.g., "https://bsky.social")
+
+
## Returns
+
+
- `{:ok, authorization_server}` - Successfully discovered authorization
+
server URL
+
- `{:error, :invalid_metadata}` - Server returned invalid metadata
+
- `{:error, reason}` - Error discovering authorization server
+
"""
+
@spec get_authorization_server(String.t()) :: {:ok, String.t()} | {:error, any()}
+
def get_authorization_server(pds_host) do
+
"#{pds_host}/.well-known/oauth-protected-resource"
+
|> Req.get()
+
|> case do
+
# TODO: what to do when multiple authorization servers?
+
{:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> {:ok, authz_server}
+
{:ok, _} -> {:error, :invalid_metadata}
+
err -> err
+
end
+
end
+
+
@doc """
+
Fetch the metadata for an OAuth authorization server.
+
+
Retrieves the metadata from the authorization server's
+
`.well-known/oauth-authorization-server` endpoint, providing endpoint URLs
+
required for the OAuth flow.
+
+
## Parameters
+
+
- `issuer` - Authorization server issuer URL
+
+
## Returns
+
+
- `{:ok, metadata}` - Successfully retrieved authorization server metadata
+
- `{:error, :invalid_metadata}` - Server returned invalid metadata
+
- `{:error, :invalid_issuer}` - Issuer mismatch in metadata
+
- `{:error, any()}` - Other error fetching metadata
+
"""
+
@spec get_authorization_server_metadata(String.t()) ::
+
{:ok, authorization_metadata()} | {:error, any()}
+
def get_authorization_server_metadata(issuer) do
+
"#{issuer}/.well-known/oauth-authorization-server"
+
|> Req.get()
+
|> case do
+
{:ok,
+
%{
+
body: %{
+
"issuer" => metadata_issuer,
+
"pushed_authorization_request_endpoint" => par_endpoint,
+
"token_endpoint" => token_endpoint,
+
"authorization_endpoint" => authorization_endpoint
+
}
+
}} ->
+
if issuer != metadata_issuer do
+
{:error, :invaild_issuer}
+
else
+
{:ok,
+
%{
+
issuer: metadata_issuer,
+
par_endpoint: par_endpoint,
+
token_endpoint: token_endpoint,
+
authorization_endpoint: authorization_endpoint
+
}}
+
end
+
+
{:ok, _} ->
+
{:error, :invalid_metadata}
+
+
err ->
+
err
+
end
+
end
+
+
@spec send_dpop_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) ::
+
{:ok, map(), String.t()} | {:error, any()}
+
defp send_dpop_request(request, dpop_key, nonce \\ nil) do
+
dpop_token = create_dpop_token(dpop_key, request, nonce)
+
+
request
+
|> Req.Request.put_header("dpop", dpop_token)
+
|> Req.request()
+
|> case do
+
{:ok, req} ->
+
dpop_nonce =
+
case req.headers["dpop-nonce"] do
+
[new_nonce | _] -> new_nonce
+
_ -> nonce
+
end
+
+
cond do
+
req.status == 200 ->
+
{:ok, req.body, dpop_nonce}
+
+
req.body["error"] === "use_dpop_nonce" ->
+
dpop_token = create_dpop_token(dpop_key, request, dpop_nonce)
+
+
request
+
|> Req.Request.put_header("dpop", dpop_token)
+
|> Req.request()
+
|> case do
+
{:ok, %{status: 200, body: body}} ->
+
{:ok, body, dpop_nonce}
+
+
{:ok, %{body: %{"error" => error, "error_description" => error_description}}} ->
+
{:error, {:oauth_error, error, error_description}}
+
+
{:ok, _} ->
+
{:error, :unexpected_response}
+
+
err ->
+
err
+
end
+
+
true ->
+
{:error, {:oauth_error, req.body["error"], req.body["error_description"]}}
+
end
+
+
err ->
+
err
+
end
+
end
+
+
@spec create_client_assertion(JOSE.JWK.t(), String.t(), String.t()) :: String.t()
+
defp create_client_assertion(jwk, client_id, issuer) do
+
iat = System.os_time(:second)
+
jti = random_b64(20)
+
jws = %{"alg" => "ES256", "kid" => jwk.fields["kid"]}
+
+
jwt = %{
+
iss: client_id,
+
sub: client_id,
+
aud: issuer,
+
jti: jti,
+
iat: iat,
+
exp: iat + 60
+
}
+
+
JOSE.JWT.sign(jwk, jws, jwt)
+
|> JOSE.JWS.compact()
+
|> elem(1)
+
end
+
+
@spec create_dpop_token(JOSE.JWK.t(), Req.Request.t(), any(), map()) :: String.t()
+
defp create_dpop_token(jwk, request, nonce \\ nil, attrs \\ %{}) do
+
iat = System.os_time(:second)
+
jti = random_b64(20)
+
{_, public_jwk} = JOSE.JWK.to_public_map(jwk)
+
jws = %{"alg" => "ES256", "typ" => "dpop+jwt", "jwk" => public_jwk}
+
[request_url | _] = request.url |> to_string() |> String.split("?")
+
+
jwt =
+
Map.merge(attrs, %{
+
jti: jti,
+
htm: atom_to_upcase_string(request.method),
+
htu: request_url,
+
iat: iat,
+
nonce: nonce
+
})
+
+
JOSE.JWT.sign(jwk, jws, jwt)
+
|> JOSE.JWS.compact()
+
|> elem(1)
+
end
+
+
@doc false
+
@spec atom_to_upcase_string(atom()) :: String.t()
+
def atom_to_upcase_string(atom) do
+
atom |> to_string() |> String.upcase()
+
end
+
end
+151
lib/atex/oauth/plug.ex
···
+
if Code.ensure_loaded?(Plug) do
+
defmodule Atex.OAuth.Plug do
+
@moduledoc """
+
Plug router for handling AT Protocol's OAuth flow.
+
+
This module provides three endpoints:
+
+
- `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for
+
a given handle
+
- `GET /callback` - Handles the OAuth callback after user authorization
+
- `GET /client-metadata.json` - Serves the OAuth client metadata
+
+
## Usage
+
+
This module requires `Plug.Session` to be in your pipeline, as well as
+
`secret_key_base` to have been set on your connections. Ideally it should be
+
routed to via `Plug.Router.forward/2`, under a route like "/oauth".
+
+
## Example
+
+
Example implementation showing how to set up the OAuth plug with proper
+
session handling:
+
+
defmodule ExampleOAuthPlug do
+
use Plug.Router
+
+
plug :put_secret_key_base
+
+
plug Plug.Session,
+
store: :cookie,
+
key: "atex-oauth",
+
signing_salt: "signing-salt"
+
+
plug :match
+
plug :dispatch
+
+
forward "/oauth", to: Atex.OAuth.Plug
+
+
def put_secret_key_base(conn, _) do
+
put_in(
+
conn.secret_key_base,
+
"very long key base with at least 64 bytes"
+
)
+
end
+
end
+
+
## Session Storage
+
+
After successful authentication, the plug stores these in the session:
+
+
* `:tokens` - The access token response containing access_token,
+
refresh_token, did, and expires_at
+
* `:dpop_key` - The DPoP JWK for generating DPoP proofs
+
"""
+
require Logger
+
use Plug.Router
+
require Plug.Router
+
alias Atex.OAuth
+
alias Atex.{IdentityResolver, IdentityResolver.DIDDocument}
+
+
@oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600]
+
+
plug :match
+
plug :dispatch
+
+
get "/login" do
+
conn = fetch_query_params(conn)
+
handle = conn.query_params["handle"]
+
+
if !handle do
+
send_resp(conn, 400, "Need `handle` query parameter")
+
else
+
case IdentityResolver.resolve(handle) do
+
{:ok, identity} ->
+
pds = DIDDocument.get_pds_endpoint(identity.document)
+
{:ok, authz_server} = OAuth.get_authorization_server(pds)
+
{:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server)
+
state = OAuth.create_nonce()
+
code_verifier = OAuth.create_nonce()
+
+
case OAuth.create_authorization_url(
+
authz_metadata,
+
state,
+
code_verifier,
+
handle
+
) do
+
{:ok, authz_url} ->
+
conn
+
|> put_resp_cookie("state", state, @oauth_cookie_opts)
+
|> put_resp_cookie("code_verifier", code_verifier, @oauth_cookie_opts)
+
|> put_resp_cookie("issuer", authz_metadata.issuer, @oauth_cookie_opts)
+
|> put_resp_header("location", authz_url)
+
|> send_resp(307, "")
+
+
err ->
+
Logger.error("failed to reate authorization url, #{inspect(err)}")
+
send_resp(conn, 500, "Internal server error")
+
end
+
+
{:error, err} ->
+
Logger.error("Failed to resolve handle, #{inspect(err)}")
+
send_resp(conn, 400, "Invalid handle")
+
end
+
end
+
end
+
+
get "/client-metadata.json" do
+
conn
+
|> put_resp_content_type("application/json")
+
|> send_resp(200, JSON.encode_to_iodata!(OAuth.create_client_metadata()))
+
end
+
+
get "/callback" do
+
conn = conn |> fetch_query_params() |> fetch_session()
+
cookies = get_cookies(conn)
+
stored_state = cookies["state"]
+
stored_code_verifier = cookies["code_verifier"]
+
stored_issuer = cookies["issuer"]
+
+
code = conn.query_params["code"]
+
state = conn.query_params["state"]
+
+
if !stored_state || !stored_code_verifier || !stored_issuer || (!code || !state) ||
+
stored_state != state do
+
send_resp(conn, 400, "Invalid request")
+
else
+
with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer),
+
dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}),
+
{:ok, tokens, nonce} <-
+
OAuth.validate_authorization_code(
+
authz_metadata,
+
dpop_key,
+
code,
+
stored_code_verifier
+
# TODO: verify did pds issuer is the same as stored issuer
+
) do
+
IO.inspect({tokens, nonce}, label: "OAuth succeeded")
+
+
conn
+
|> put_session(:tokens, tokens)
+
|> put_session(:dpop_key, dpop_key)
+
|> send_resp(200, "success!! hello #{tokens.did}")
+
else
+
err ->
+
Logger.error("failed to validate oauth callback: #{inspect(err)}")
+
send_resp(conn, 500, "Internal server error")
+
end
+
end
+
end
+
end
+
end
+11 -9
lib/atex/xrpc.ex
···
defmodule Atex.XRPC do
-
alias Atex.{HTTP, XRPC}
+
alias Atex.XRPC
# TODO: automatic user-agent, and env for changing it
···
@doc """
Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
"""
-
@spec get(XRPC.Client.t(), String.t(), keyword()) :: HTTP.Adapter.result()
+
@spec get(XRPC.Client.t(), String.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, any()}
def get(%XRPC.Client{} = client, name, opts \\ []) do
opts = put_auth(opts, client.access_token)
-
HTTP.get(url(client, name), opts)
+
Req.get(url(client, name), opts)
end
@doc """
Perform a HTTP POST on a XRPC resource. Called a "prodecure" in lexicons.
"""
-
@spec post(XRPC.Client.t(), String.t(), keyword()) :: HTTP.Adapter.result()
+
@spec post(XRPC.Client.t(), String.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, any()}
def post(%XRPC.Client{} = client, name, opts \\ []) do
# TODO: look through available HTTP clients and see if they have a
# consistent way of providing JSON bodies with auto content-type. If not,
# create one for adapters.
opts = put_auth(opts, client.access_token)
-
HTTP.post(url(client, name), opts)
+
Req.post(url(client, name), opts)
end
@doc """
Like `get/3` but is unauthenticated by default.
"""
-
@spec unauthed_get(String.t(), String.t(), keyword()) :: HTTP.Adapter.result()
+
@spec unauthed_get(String.t(), String.t(), keyword()) ::
+
{:ok, Req.Response.t()} | {:error, any()}
def unauthed_get(endpoint, name, opts \\ []) do
-
HTTP.get(url(endpoint, name), opts)
+
Req.get(url(endpoint, name), opts)
end
@doc """
Like `post/3` but is unauthenticated by default.
"""
-
@spec unauthed_post(String.t(), String.t(), keyword()) :: HTTP.Adapter.result()
+
@spec unauthed_post(String.t(), String.t(), keyword()) ::
+
{:ok, Req.Response.t()} | {:error, any()}
def unauthed_post(endpoint, name, opts \\ []) do
-
HTTP.post(url(endpoint, name), opts)
+
Req.post(url(endpoint, name), opts)
end
# TODO: use URI module for joining instead?
+3 -3
lib/atex/xrpc/client.ex
···
iex> Atex.XRPC.Client.login("https://bsky.social", "example.com", "password123")
{:ok, %Atex.XRPC.Client{...}}
"""
-
@spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | HTTP.Adapter.error()
+
@spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, any()}
@spec login(String.t(), String.t(), String.t(), String.t() | nil) ::
-
{:ok, t()} | HTTP.Adapter.error()
+
{:ok, t()} | {:error, any()}
def login(endpoint, identifier, password, auth_factor_token \\ nil) do
json =
%{identifier: identifier, password: password}
···
@doc """
Request a new `refresh_token` for the given client.
"""
-
@spec refresh(t()) :: {:ok, t()} | HTTP.Adapter.error()
+
@spec refresh(t()) :: {:ok, t()} | {:error, any()}
def refresh(%__MODULE__{endpoint: endpoint, refresh_token: refresh_token} = client) do
response =
XRPC.unauthed_post(
+1 -1
lib/mix/tasks/atex.lexicons.ex
···
@aliases [o: :output]
@template_path Path.expand("../../../priv/templates/lexicon.eex", __DIR__)
-
@impl Mix.Task
+
@impl true
def run(args) do
{options, globs} = OptionParser.parse!(args, switches: @switches, aliases: @aliases)
+4 -1
mix.exs
···
{:typedstruct, "~> 0.5"},
{:ex_cldr, "~> 2.42"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
-
{:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true}
+
{:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true},
+
{:plug, "~> 1.18", optional: true},
+
{:jose, git: "https://github.com/potatosalad/erlang-jose.git", ref: "main"},
+
{:bandit, "~> 1.0", only: [:dev, :test]}
]
end
+8 -2
mix.lock
···
%{
+
"bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"cldr_utils": {:hex, :cldr_utils, "2.28.3", "d0ac5ed25913349dfaca8b7fe14722d588d8ccfa3e335b0510c7cc3f3c54d4e6", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "40083cd9a5d187f12d675cfeeb39285f0d43e7b7f2143765161b72205d57ffb5"},
"credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
-
"ex_cldr": {:hex, :ex_cldr, "2.42.0", "17ea930e88b8802b330e1c1e288cdbaba52cbfafcccf371ed34b299a47101ffb", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "07264a7225810ecae6bdd6715d8800c037a1248dc0063923cddc4ca3c4888df6"},
+
"ex_cldr": {:hex, :ex_cldr, "2.43.0", "8700031e30a03501cf65f7ba7c8287bb67339d03559f3108f3c54fe86d926b19", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "1524eb01275b89473ee5f53fcc6169bae16e4a5267ef109229f37694799e0b20"},
"ex_doc": {:hex, :ex_doc, "0.38.3", "ddafe36b8e9fe101c093620879f6604f6254861a95133022101c08e75e6c759a", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "ecaa785456a67f63b4e7d7f200e8832fa108279e7eb73fd9928e7e66215a01f9"},
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
+
"jose": {:git, "https://github.com/potatosalad/erlang-jose.git", "e6e6be719695e55618a56416be4d7934dd81deba", [ref: "main"]},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
···
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"peri": {:hex, :peri, "0.6.1", "6a90ca728a27aef8fef37ce307444255d20364b0c8f8d39e52499d8d825cb514", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e20ffc659967baf9c4f28799fe7302b656d6662a8b3db7646fdafd017e192743"},
+
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
+
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"recase": {:hex, :recase, "0.9.0", "437982693fdfbec125f11c8868eb3b4d32e9aa6995d3a68ac8686f3e2bf5d8d1", [:mix], [], "hexpm", "efa7549ebd128988d1723037a6f6a61948055aec107db6288f1c52830cb6501c"},
"req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
-
"typedstruct": {:hex, :typedstruct, "0.5.3", "d68ae424251a41b81a8d0c485328ab48edbd3858f3565bbdac21b43c056fc9b4", [:make, :mix], [], "hexpm", "b53b8186701417c0b2782bf02a2db5524f879b8488f91d1d83b97d84c2943432"},
+
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
+
"typedstruct": {:hex, :typedstruct, "0.5.4", "d1d33d58460a74f413e9c26d55e66fd633abd8ac0fb12639add9a11a60a0462a", [:make, :mix], [], "hexpm", "ffaef36d5dbaebdbf4ed07f7fb2ebd1037b2c1f757db6fb8e7bcbbfabbe608d8"},
"varint": {:hex, :varint, "1.5.1", "17160c70d0428c3f8a7585e182468cac10bbf165c2360cf2328aaa39d3fb1795", [:mix], [], "hexpm", "24f3deb61e91cb988056de79d06f01161dd01be5e0acae61d8d936a552f1be73"},
+
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
}