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 dpop.gleam
Raw

src/atproto/browser/dpop.gleam

//// Browser-native DPoP (RFC 9449) key generation and proof signing, backed
//// by WebCrypto (`crypto.subtle`). `atproto/oauth/dpop.gleam`'s `gose`/
//// `kryptos`-backed implementation compiles for the javascript target but
//// cannot run in a real browser tab: `kryptos`'s JS FFI is `node:crypto`-only,
//// with no WebCrypto anywhere in it. This module is not a drop-in for
//// `dpop.gleam` (different key type — a real, non-extractable WebCrypto
//// `CryptoKeyPair`, not a `gose.Key` — and every operation is async, since
//// WebCrypto itself is) — it's for a from-scratch browser OAuth client. It
//// produces byte-identical DPoP proof JWTs otherwise: same claims, same
//// ES256-over-P-256 signing.
import gleam/bit_array
import gleam/crypto
import gleam/float
import gleam/javascript/promise.{type Promise}
import gleam/json.{type Json}
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/time/timestamp
/// An opaque WebCrypto keypair handle (a native `CryptoKeyPair`). Never
/// constructed or inspected from Gleam directly — every operation on it
/// goes through this module's FFI, so the private key material never
/// crosses into Gleam-visible values.
pub type DpopKey
@external(javascript, "./dpop_ffi.mjs", "generateKey")
pub fn generate_key() -> Promise(DpopKey)
@external(javascript, "./dpop_ffi.mjs", "publicJwkCoords")
fn ffi_public_jwk_coords(
key: DpopKey,
) -> Promise(Result(#(String, String), String))
/// The public key as a bare JWK (kty/crv/x/y), for embedding in a DPoP proof
/// header — mirrors `atproto/oauth/dpop.bare_public_jwk`.
pub fn bare_public_jwk(key: DpopKey) -> Promise(Result(Json, String)) {
use coords <- promise.map(ffi_public_jwk_coords(key))
case coords {
Ok(#(x, y)) ->
Ok(
json.object([
#("crv", json.string("P-256")),
#("kty", json.string("EC")),
#("x", json.string(x)),
#("y", json.string(y)),
]),
)
Error(e) -> Error(e)
}
}
@external(javascript, "./dpop_ffi.mjs", "signEs256")
fn ffi_sign(
key: DpopKey,
signing_input: String,
) -> Promise(Result(String, String))
/// Sign one DPoP proof JWT for a request. Claims match
/// `atproto/oauth/dpop.proof` exactly (jti/htm/htu/iat, optional nonce/ath).
pub fn proof(
key: DpopKey,
method method: String,
url url: String,
nonce nonce: Option(String),
ath ath: Option(String),
) -> Promise(Result(String, String)) {
use jwk_result <- promise.await(bare_public_jwk(key))
case jwk_result {
Error(e) -> promise.resolve(Error(e))
Ok(jwk) -> {
let header =
json.object([
#("typ", json.string("dpop+jwt")),
#("alg", json.string("ES256")),
#("jwk", jwk),
])
|> base64url_json
let jti = base64url(crypto.strong_random_bytes(16))
let iat =
float.truncate(timestamp.to_unix_seconds(timestamp.system_time()))
let payload =
[
#("jti", json.string(jti)),
#("htm", json.string(method)),
#("htu", json.string(url)),
#("iat", json.int(iat)),
]
|> push("nonce", nonce)
|> push("ath", ath)
|> json.object
|> base64url_json
let signing_input = header <> "." <> payload
use signed <- promise.map(ffi_sign(key, signing_input))
case signed {
Ok(signature) -> Ok(signing_input <> "." <> signature)
Error(e) -> Error(e)
}
}
}
}
fn push(
claims: List(#(String, Json)),
key: String,
value: Option(String),
) -> List(#(String, Json)) {
case value {
Some(v) -> list.append(claims, [#(key, json.string(v))])
None -> claims
}
}
fn base64url(bits: BitArray) -> String {
bit_array.base64_url_encode(bits, False)
}
fn base64url_json(value: Json) -> String {
json.to_string(value) |> bit_array.from_string |> base64url
}