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

feat: oauth xrpc client

Changed files
+483 -141
lib
+7 -5
CHANGELOG.md
···
something more fleshed out once we're more stable.
- Rename `Atex.XRPC.Client` to `Atex.XRPC.LoginClient`
-
### Features
+
### Added
-
- 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
+
- `Atex.OAuth` module with utilites for handling some OAuth functionality.
+
- `Atex.OAuth.Plug` module (if Plug is loaded) which provides a basic but
complete OAuth flow, including storing the tokens in `Plug.Session`.
-
- Add `Atex.XRPC.Client` behaviour for implementing custom client variants.
-
- `Atex.XRPC` now delegates get/post options to the provided client struct.
+
- `Atex.XRPC.Client` behaviour for implementing custom client variants.
+
- `Atex.XRPC` now supports using different client implementations.
+
- `Atex.XRPC.OAuthClient` to make XRPC calls on the behalf of a user who has
+
authenticated with ATProto OAuth.
## [0.4.0] - 2025-08-27
+1 -1
README.md
···
- [x] DID & handle resolution service with a cache
- [x] Macro for converting a Lexicon definition into a runtime-validation schema
- [x] Codegen to convert a directory of lexicons
+
- [x] Oauth stuff
- [ ] Extended XRPC client with support for validated inputs/outputs
-
- [ ] Oauth stuff
## Installation
+116 -17
lib/atex/oauth.ex
···
{_, 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()],
···
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)
···
}
Req.new(method: :post, url: authz_metadata.token_endpoint, form: body)
-
|> send_dpop_request(dpop_key)
+
|> send_oauth_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
+
+
def refresh_token(refresh_token, dpop_key, issuer, token_endpoint) do
+
key = get_key()
+
+
client_assertion =
+
create_client_assertion(key, Config.client_id(), issuer)
+
+
body = %{
+
grant_type: "refresh_token",
+
refresh_token: refresh_token,
+
client_id: Config.client_id(),
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
+
client_assertion: client_assertion
+
}
+
+
Req.new(method: :post, url: token_endpoint, form: body)
+
|> send_oauth_dpop_request(dpop_key)
|> case do
{:ok,
%{
···
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
+
@spec send_oauth_dpop_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) ::
+
{:ok, map(), String.t()} | {:error, any(), String.t()}
+
def send_oauth_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} ->
+
{:ok, resp} ->
dpop_nonce =
-
case req.headers["dpop-nonce"] do
+
case resp.headers["dpop-nonce"] do
[new_nonce | _] -> new_nonce
_ -> nonce
end
cond do
-
req.status == 200 ->
-
{:ok, req.body, dpop_nonce}
+
resp.status == 200 ->
+
{:ok, resp.body, dpop_nonce}
-
req.body["error"] === "use_dpop_nonce" ->
+
resp.body["error"] === "use_dpop_nonce" ->
dpop_token = create_dpop_token(dpop_key, request, dpop_nonce)
request
···
{:ok, body, dpop_nonce}
{:ok, %{body: %{"error" => error, "error_description" => error_description}}} ->
-
{:error, {:oauth_error, error, error_description}}
+
{:error, {:oauth_error, error, error_description}, dpop_nonce}
{:ok, _} ->
-
{:error, :unexpected_response}
+
{:error, :unexpected_response, dpop_nonce}
-
err ->
-
err
+
{:error, err} ->
+
{:error, err, dpop_nonce}
end
true ->
-
{:error, {:oauth_error, req.body["error"], req.body["error_description"]}}
+
{:error, {:oauth_error, resp.body["error"], resp.body["error_description"]},
+
dpop_nonce}
+
end
+
+
{:error, err} ->
+
{:error, err, nonce}
+
end
+
end
+
+
@spec request_protected_dpop_resource(
+
Req.Request.t(),
+
String.t(),
+
String.t(),
+
JOSE.JWK.t(),
+
String.t() | nil
+
) :: {:ok, Req.Response.t(), String.t() | nil} | {:error, any()}
+
def request_protected_dpop_resource(request, issuer, access_token, dpop_key, nonce \\ nil) do
+
access_token_hash = :crypto.hash(:sha256, access_token) |> Base.url_encode64(padding: false)
+
# access_token_hash = Base.url_encode64(access_token, padding: false)
+
+
dpop_token =
+
create_dpop_token(dpop_key, request, nonce, %{iss: issuer, ath: access_token_hash})
+
+
request
+
|> Req.Request.put_header("dpop", dpop_token)
+
|> Req.request()
+
|> case do
+
{:ok, resp} ->
+
dpop_nonce =
+
case resp.headers["dpop-nonce"] do
+
[new_nonce | _] -> new_nonce
+
_ -> nonce
+
end
+
+
www_authenticate = Req.Response.get_header(resp, "www-authenticate")
+
+
www_dpop_problem =
+
www_authenticate != [] && String.starts_with?(Enum.at(www_authenticate, 0), "DPoP")
+
+
if resp.status != 401 || !www_dpop_problem do
+
{:ok, resp, dpop_nonce}
+
else
+
dpop_token =
+
create_dpop_token(dpop_key, request, dpop_nonce, %{
+
iss: issuer,
+
ath: access_token_hash
+
})
+
+
request
+
|> Req.Request.put_header("dpop", dpop_token)
+
|> Req.request()
+
|> case do
+
{:ok, resp} ->
+
dpop_nonce =
+
case resp.headers["dpop-nonce"] do
+
[new_nonce | _] -> new_nonce
+
_ -> dpop_nonce
+
end
+
+
{:ok, resp, dpop_nonce}
+
+
err ->
+
err
+
end
end
err ->
+130 -116
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.
+
defmodule Atex.OAuth.Plug do
+
@moduledoc """
+
Plug router for handling AT Protocol's OAuth flow.
-
This module provides three endpoints:
+
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
+
- `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
+
## 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".
+
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
-
Example implementation showing how to set up the OAuth plug with proper
-
session handling:
+
Example implementation showing how to set up the OAuth plug with proper
+
session handling:
-
defmodule ExampleOAuthPlug do
-
use Plug.Router
+
defmodule ExampleOAuthPlug do
+
use Plug.Router
-
plug :put_secret_key_base
+
plug :put_secret_key_base
-
plug Plug.Session,
-
store: :cookie,
-
key: "atex-oauth",
-
signing_salt: "signing-salt"
+
plug Plug.Session,
+
store: :cookie,
+
key: "atex-oauth",
+
signing_salt: "signing-salt"
-
plug :match
-
plug :dispatch
+
plug :match
+
plug :dispatch
-
forward "/oauth", to: Atex.OAuth.Plug
+
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
+
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
+
## Session Storage
-
After successful authentication, the plug stores these in the session:
+
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}
+
- `:tokens` - The access token response containing access_token,
+
refresh_token, did, and expires_at
+
- `:dpop_nonce` -
+
- `: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]
+
@oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600]
-
plug :match
-
plug :dispatch
+
plug :match
+
plug :dispatch
-
get "/login" do
-
conn = fetch_query_params(conn)
-
handle = conn.query_params["handle"]
+
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()
+
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, "")
+
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
+
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
+
{: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 "/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"]
+
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"]
+
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")
+
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
+
),
+
{:ok, identity} <- IdentityResolver.resolve(tokens.did),
+
# Make sure pds' issuer matches the stored one (just in case)
+
pds <- DIDDocument.get_pds_endpoint(identity.document),
+
{:ok, authz_server} <- OAuth.get_authorization_server(pds),
+
true <- authz_server == stored_issuer do
+
conn
+
|> delete_resp_cookie("state", @oauth_cookie_opts)
+
|> delete_resp_cookie("code_verifier", @oauth_cookie_opts)
+
|> delete_resp_cookie("issuer", @oauth_cookie_opts)
+
|> put_session(:atex_oauth, %{
+
access_token: tokens.access_token,
+
refresh_token: tokens.refresh_token,
+
did: tokens.did,
+
pds: pds,
+
expires_at: tokens.expires_at,
+
dpop_nonce: nonce,
+
dpop_key: dpop_key
+
})
+
|> send_resp(200, "success!! hello #{tokens.did}")
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")
+
false ->
+
send_resp(conn, 400, "OAuth issuer does not match your PDS' authorization server")
-
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
+
err ->
+
Logger.error("failed to validate oauth callback: #{inspect(err)}")
+
send_resp(conn, 500, "Internal server error")
end
end
end
-1
lib/atex/xrpc.ex
···
Req.post(url(endpoint, name), opts)
end
-
# TODO: use URI module for joining instead?
@doc """
Create an XRPC url based on an endpoint and a resource name.
+228
lib/atex/xrpc/oauth_client.ex
···
+
defmodule Atex.XRPC.OAuthClient do
+
alias Atex.OAuth
+
alias Atex.XRPC
+
use TypedStruct
+
+
@behaviour Atex.XRPC.Client
+
+
typedstruct enforce: true do
+
field :endpoint, String.t()
+
field :issuer, String.t()
+
field :access_token, String.t()
+
field :refresh_token, String.t()
+
field :did, String.t()
+
field :expires_at, NaiveDateTime.t()
+
field :dpop_nonce, String.t() | nil, enforce: false
+
field :dpop_key, JOSE.JWK.t()
+
end
+
+
@doc """
+
Create a new OAuthClient struct.
+
"""
+
@spec new(
+
String.t(),
+
String.t(),
+
String.t(),
+
String.t(),
+
NaiveDateTime.t(),
+
JOSE.JWK.t(),
+
String.t() | nil
+
) :: t()
+
def new(endpoint, did, access_token, refresh_token, expires_at, dpop_key, dpop_nonce) do
+
{:ok, issuer} = OAuth.get_authorization_server(endpoint)
+
+
%__MODULE__{
+
endpoint: endpoint,
+
issuer: issuer,
+
access_token: access_token,
+
refresh_token: refresh_token,
+
did: did,
+
expires_at: expires_at,
+
dpop_nonce: dpop_nonce,
+
dpop_key: dpop_key
+
}
+
end
+
+
@doc """
+
Create an OAuthClient struct from a `Plug.Conn`.
+
+
Requires the conn to have passed through `Plug.Session` and
+
`Plug.Conn.fetch_session/2` so that the session can be acquired and have the
+
`atex_oauth` key fetched from it.
+
+
Returns `:error` if the state is missing or is not the expected shape.
+
"""
+
@spec from_conn(Plug.Conn.t()) :: {:ok, t()} | :error
+
def from_conn(%Plug.Conn{} = conn) do
+
oauth_state = Plug.Conn.get_session(conn, :atex_oauth)
+
+
case oauth_state do
+
%{
+
access_token: access_token,
+
refresh_token: refresh_token,
+
did: did,
+
pds: pds,
+
expires_at: expires_at,
+
dpop_nonce: dpop_nonce,
+
dpop_key: dpop_key
+
} ->
+
{:ok, new(pds, did, access_token, refresh_token, expires_at, dpop_key, dpop_nonce)}
+
+
_ ->
+
:error
+
end
+
end
+
+
@doc """
+
Updates a `Plug.Conn` session with the latest values from the client.
+
+
Ideally should be called at the end of routes where XRPC calls occur, in case
+
the client has transparently refreshed, so that the user is always up to date.
+
"""
+
@spec update_plug(Plug.Conn.t(), t()) :: Plug.Conn.t()
+
def update_plug(%Plug.Conn{} = conn, %__MODULE__{} = client) do
+
Plug.Conn.put_session(conn, :atex_oauth, %{
+
access_token: client.access_token,
+
refresh_token: client.refresh_token,
+
did: client.did,
+
pds: client.endpoint,
+
expires_at: client.expires_at,
+
dpop_nonce: client.dpop_nonce,
+
dpop_key: client.dpop_key
+
})
+
end
+
+
@doc """
+
Ask the client's OAuth server for a new set of auth tokens.
+
+
You shouldn't need to call this manually for the most part, the client does
+
it's best to refresh automatically when it needs to.
+
"""
+
@spec refresh(t()) :: {:ok, t()} | {:error, any()}
+
def refresh(%__MODULE__{} = client) do
+
with {:ok, authz_server} <- OAuth.get_authorization_server(client.endpoint),
+
{:ok, %{token_endpoint: token_endpoint}} <-
+
OAuth.get_authorization_server_metadata(authz_server) do
+
case OAuth.refresh_token(
+
client.refresh_token,
+
client.dpop_key,
+
client.issuer,
+
token_endpoint
+
) do
+
{:ok, tokens, nonce} ->
+
{:ok,
+
%{
+
client
+
| access_token: tokens.access_token,
+
refresh_token: tokens.refresh_token,
+
dpop_nonce: nonce
+
}}
+
+
err ->
+
err
+
end
+
end
+
end
+
+
@doc """
+
See `Atex.XRPC.get/3`.
+
"""
+
@impl true
+
def get(%__MODULE__{} = client, resource, opts \\ []) do
+
request(client, opts ++ [method: :get, url: XRPC.url(client.endpoint, resource)])
+
end
+
+
@doc """
+
See `Atex.XRPC.post/3`.
+
"""
+
@impl true
+
def post(%__MODULE__{} = client, resource, opts \\ []) do
+
request(client, opts ++ [method: :post, url: XRPC.url(client.endpoint, resource)])
+
end
+
+
@spec request(t(), keyword()) :: {:ok, Req.Response.t(), t()} | {:error, any(), any()}
+
defp request(client, opts) do
+
# Preemptively refresh token if it's about to expire
+
with {:ok, client} <- maybe_refresh(client) do
+
request = opts |> Req.new() |> put_auth(client.access_token)
+
+
case OAuth.request_protected_dpop_resource(
+
request,
+
client.issuer,
+
client.access_token,
+
client.dpop_key,
+
client.dpop_nonce
+
) do
+
{:ok, %{status: 200} = response, nonce} ->
+
client = %{client | dpop_nonce: nonce}
+
{:ok, response, client}
+
+
{:ok, response, nonce} ->
+
client = %{client | dpop_nonce: nonce}
+
handle_failure(client, response, request)
+
+
err ->
+
err
+
end
+
end
+
end
+
+
@spec handle_failure(t(), Req.Response.t(), Req.Request.t()) ::
+
{:ok, Req.Response.t(), t()} | {:error, any(), t()}
+
defp handle_failure(client, response, request) do
+
IO.inspect(response, label: "got failure")
+
+
if auth_error?(response.body) and client.refresh_token do
+
case refresh(client) do
+
{:ok, client} ->
+
case OAuth.request_protected_dpop_resource(
+
request,
+
client.issuer,
+
client.access_token,
+
client.dpop_key,
+
client.dpop_nonce
+
) do
+
{:ok, %{status: 200} = response, nonce} ->
+
{:ok, response, %{client | dpop_nonce: nonce}}
+
+
{:ok, response, nonce} ->
+
{:error, response, %{client | dpop_nonce: nonce}}
+
+
{:error, err} ->
+
{:error, err, client}
+
end
+
+
err ->
+
err
+
end
+
else
+
{:error, response, client}
+
end
+
end
+
+
@spec maybe_refresh(t(), integer()) :: {:ok, t()} | {:error, any()}
+
defp maybe_refresh(%__MODULE__{expires_at: expires_at} = client, buffer_minutes \\ 5) do
+
if token_expiring_soon?(expires_at, buffer_minutes) do
+
refresh(client)
+
else
+
{:ok, client}
+
end
+
end
+
+
@spec token_expiring_soon?(NaiveDateTime.t(), integer()) :: boolean()
+
defp token_expiring_soon?(expires_at, buffer_minutes) do
+
now = NaiveDateTime.utc_now()
+
expiry_threshold = NaiveDateTime.add(now, buffer_minutes * 60, :second)
+
+
NaiveDateTime.compare(expires_at, expiry_threshold) in [:lt, :eq]
+
end
+
+
@spec auth_error?(body :: Req.Response.t()) :: boolean()
+
defp auth_error?(%{status: status}) when status in [401, 403], do: true
+
defp auth_error?(%{body: %{"error" => "InvalidToken"}}), do: true
+
defp auth_error?(_response), do: false
+
+
@spec put_auth(Req.Request.t(), String.t()) :: Req.Request.t()
+
defp put_auth(request, token),
+
do: Req.Request.put_header(request, "authorization", "DPoP #{token}")
+
end
+1 -1
mix.exs
···
{:ex_cldr, "~> 2.42"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true},
-
{:plug, "~> 1.18", optional: true},
+
{:plug, "~> 1.18"},
{:jose, git: "https://github.com/potatosalad/erlang-jose.git", ref: "main"},
{:bandit, "~> 1.0", only: [:dev, :test]}
]