Current section
Files
Jump to
Current section
Files
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.
import atproto/identity
import atproto/oauth/dpop
import atproto/oauth/metadata
import atproto/oauth/pkce
import atproto/oauth/transport
import atproto/xrpc.{type Client}
import gleam/bit_array
import gleam/crypto
import gleam/dynamic/decode
import gleam/int
import gleam/list
import gleam/result
import gleam/string
import gleam/uri
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)
DpopFailed(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) {
use pds <- result.try(
identity.resolve_pds(client, resolver, identifier)
|> result.map_error(fn(e) { ResolveFailed(string.inspect(e)) }),
)
use meta <- result.try(
metadata.discover(client, pds)
|> result.map_error(fn(e) { DiscoverFailed(string.inspect(e)) }),
)
let pk = pkce.generate()
let dpop_key = dpop.generate_key()
let state = b64(crypto.strong_random_bytes(16))
let form =
list.append(
[
#("client_id", client_id),
#("response_type", "code"),
#("code_challenge", pk.challenge),
#("code_challenge_method", "S256"),
#("redirect_uri", redirect_uri),
#("scope", scope),
#("state", state),
#("login_hint", identifier),
],
extra_form,
)
use request_uri <- result.try(push_par(client, meta, form, dpop_key))
let flow =
Flow(
identifier:,
pds:,
issuer: meta.issuer,
token_endpoint: meta.token_endpoint,
dpop_key:,
pkce_verifier: pk.verifier,
client_id:,
state:,
)
let redirect_url =
meta.authorization_endpoint
<> "?"
<> uri.query_to_string([
#("client_id", client_id),
#("request_uri", request_uri),
])
Ok(#(redirect_url, flow))
}
fn push_par(
client: Client,
meta: metadata.AuthServerMetadata,
form: List(#(String, String)),
dpop_key: gose.Key(String),
) -> Result(String, FlowError) {
use resp <- result.try(
transport.post_form_with_dpop(
client,
meta.pushed_authorization_request_endpoint,
form,
dpop_key,
)
|> result.map_error(ParFailed),
)
case resp.status >= 200 && resp.status < 300 {
True ->
xrpc.parse(resp.body, decode.at(["request_uri"], decode.string))
|> result.map_error(fn(e) { ParFailed(string.inspect(e)) })
False ->
Error(ParFailed("PAR " <> int.to_string(resp.status) <> ": " <> resp.body))
}
}
fn b64(bits: BitArray) -> String {
bit_array.base64_url_encode(bits, False)
}