Packages

A small, transport-agnostic atproto client for Gleam: XRPC, identity, OAuth discovery, blobs, and repo CRUD.

Current section

Files

Jump to
atproto_client src atproto oauth flow.gleam
Raw

src/atproto/oauth/flow.gleam

//// Start an authorization flow: resolve the PDS, discover the authorization
//// server, push the request (PAR) with PKCE + DPoP, return the authorization
//// URL and the pending `Flow` the caller must hold (server-side by state, or
//// in-process for a CLI) until the callback returns with the code.
////
//// Confidential clients pass their client-assertion fields via `extra_form`;
//// public and loopback clients pass an empty list.
////
//// A thin sync wrapper over the effect kernel in `atproto/oauth/core/flow`:
//// this module generates the platform one-shots (PKCE, DPoP key, state) and
//// folds the key into the `Flow` record the kernel deliberately never sees.
import atproto/oauth/core/flow as core
import atproto/oauth/dpop
import atproto/oauth/pkce
import atproto/oauth/runner
import atproto/xrpc.{type Client}
import gleam/bit_array
import gleam/crypto
import gose
pub type Flow {
Flow(
identifier: String,
pds: String,
issuer: String,
token_endpoint: String,
dpop_key: gose.Key(String),
pkce_verifier: String,
client_id: String,
state: String,
)
}
pub type FlowError {
ResolveFailed(String)
DiscoverFailed(String)
ParFailed(String)
}
/// Returns the authorization-server URL to send the user to, and the pending
/// flow. The caller must verify the callback's `state` against `flow.state`
/// before exchanging the code.
pub fn start(
client: Client,
resolver resolver: String,
identifier identifier: String,
client_id client_id: String,
redirect_uri redirect_uri: String,
scope scope: String,
extra_form extra_form: List(#(String, String)),
) -> Result(#(String, Flow), FlowError) {
let pkce_pair = pkce.generate()
let dpop_key = dpop.generate_key()
let state = base64url(crypto.strong_random_bytes(16))
let started =
core.start(
resolver:,
identifier:,
client_id:,
redirect_uri:,
scope:,
pkce_challenge: pkce_pair.challenge,
state:,
extra_form:,
)
|> runner.run(client, dpop_key)
case started {
Error(e) -> Error(to_flow_error(e))
Ok(auth) ->
Ok(#(
auth.redirect_url,
Flow(
identifier:,
pds: auth.pds,
issuer: auth.issuer,
token_endpoint: auth.token_endpoint,
dpop_key:,
pkce_verifier: pkce_pair.verifier,
client_id:,
state:,
),
))
}
}
fn to_flow_error(e: core.StartError) -> FlowError {
case e {
core.ResolveFailed(m) -> ResolveFailed(m)
core.DiscoverFailed(m) -> DiscoverFailed(m)
core.ParFailed(m) -> ParFailed(m)
}
}
fn base64url(bits: BitArray) -> String {
bit_array.base64_url_encode(bits, False)
}