Packages

A browser-native atproto OAuth + XRPC client for Gleam: WebCrypto DPoP, PKCE, and async transport, for building a public-client SPA with no server-side session custody.

Current section

Files

Jump to
atproto_browser src atproto browser oauth flow.gleam
Raw

src/atproto/browser/oauth/flow.gleam

//// Async mirror of `atproto/oauth/flow.gleam`: start an authorization flow
//// (resolve the PDS, discover the authorization server, push the request
//// (PAR) with PKCE + DPoP), returning the authorization URL and the pending
//// `Flow` the caller must hold (e.g. in `sessionStorage`, or an in-memory
//// store keyed by `state`) until the callback returns with the code.
////
//// A thin async wrapper over the shared effect kernel in
//// `atproto_core/oauth/flow`: it generates the browser
//// one-shots (WebCrypto PKCE, WebCrypto DPoP key, state) and folds the key
//// into the `Flow` record the kernel never sees. Meant for a public client;
//// confidential-client fields still go through `extra_form`.
import atproto/browser/dpop.{type DpopKey}
import atproto/browser/oauth/runner
import atproto/browser/pkce
import atproto/browser/xrpc.{type Client}
import atproto_core/oauth/flow as core
import gleam/bit_array
import gleam/crypto
import gleam/javascript/promise.{type Promise}
pub type Flow {
Flow(
identifier: String,
pds: String,
issuer: String,
token_endpoint: String,
dpop_key: DpopKey,
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)),
) -> Promise(Result(#(String, Flow), FlowError)) {
use pkce_pair <- promise.await(pkce.generate())
let state = base64url(crypto.strong_random_bytes(16))
use dpop_key <- promise.await(dpop.generate_key())
use started <- promise.map(runner.run(
core.start(
resolver:,
identifier:,
client_id:,
redirect_uri:,
scope:,
pkce_challenge: pkce_pair.challenge,
state:,
extra_form:,
),
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)
}