Current section
Files
Jump to
Current section
Files
src/atproto/oauth/resource.gleam
//// DPoP-authenticated resource requests: wraps a transport so every request
//// carries `Authorization: DPoP <token>` and a proof bound to its method, URL,
//// and access-token hash, with the server-nonce retry. Token refresh is the
//// caller's job (it owns session persistence); this module only signs.
import atproto/oauth/dpop
import atproto/oauth/transport
import atproto/xrpc.{type Client}
import gleam/bit_array
import gleam/crypto
import gleam/http
import gleam/http/request.{type Request}
import gleam/http/response.{type Response}
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
import gleam/uri
import gose
/// A client that signs every request against the given DPoP-bound access token.
pub fn client(
base: Client,
access_token access_token: String,
dpop_key dpop_key: gose.Key(String),
) -> Client {
xrpc.Client(send: fn(req) { dpop_send(base, access_token, dpop_key, req) })
}
fn dpop_send(
base: Client,
access_token: String,
dpop_key: gose.Key(String),
req: Request(BitArray),
) -> Result(Response(BitArray), String) {
let method = http.method_to_string(req.method) |> string.uppercase
let target = htu(req)
let ath = b64(crypto.hash(crypto.Sha256, bit_array.from_string(access_token)))
use first <- result.try(send_once(
base,
access_token,
dpop_key,
req,
method,
target,
ath,
None,
))
case transport.dpop_nonce_challenge(as_text(first)) {
Some(nonce) ->
send_once(
base,
access_token,
dpop_key,
req,
method,
target,
ath,
Some(nonce),
)
None -> Ok(first)
}
}
// Only JSON bodies are sniffed for use_dpop_nonce; binary is never scanned.
fn as_text(resp: Response(BitArray)) -> Response(String) {
let json_body = case response.get_header(resp, "content-type") {
Ok(content_type) -> string.starts_with(content_type, "application/json")
Error(Nil) -> False
}
response.map(resp, fn(body) {
case json_body {
True -> bit_array.to_string(body) |> result.unwrap("")
False -> ""
}
})
}
fn send_once(
base: Client,
access_token: String,
dpop_key: gose.Key(String),
req: Request(BitArray),
method: String,
target: String,
ath: String,
nonce: Option(String),
) -> Result(Response(BitArray), String) {
use proof <- result.try(dpop.proof(
dpop_key,
method:,
url: target,
nonce:,
ath: Some(ath),
))
req
|> request.set_header("authorization", "DPoP " <> access_token)
|> request.set_header("dpop", proof)
|> base.send
}
/// The DPoP `htu`: the request URI without query or fragment.
fn htu(req: Request(a)) -> String {
let u = request.to_uri(req)
uri.to_string(uri.Uri(..u, query: None, fragment: None))
}
fn b64(bits: BitArray) -> String {
bit_array.base64_url_encode(bits, False)
}