Something went wrong. Try again.
Atproto primitives for Gleam.
atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204// IMPORTS ---------------------------------------------------------------------
import at/didimport at/did_documentimport at/oauth.{type DpopRequest, type Session, type Verified}import eweimport gleam/erlang/processimport gleam/http/response.{type Response}import gleam/httpcimport gleam/ioimport gleam/jsonimport gleam/option.{type Option, None, Some}import gleam/resultimport gleam/uriimport ywtimport ywt/algorithmimport ywt/sign_key.{type SignKey}import ywt/verify_key
// MAIN ------------------------------------------------------------------------
pub fn main() -> Nil { // 0. Set up the identity we want to authenticate as, and do basic discovery // to resolve the did document and the PDS that hosts the account. let assert Ok(hayleigh) = did.from_string("did:plc:j67yqsbcjczp73i3b73ei2kj") let assert Ok(doc) = hayleigh |> did.document_request |> httpc.send let assert Ok(doc) = json.parse(doc.body, did_document.decoder()) let assert Ok(pds) = did_document.pds(doc)
// 1. Locate the authorisation server that let assert Ok(request) = oauth.query_resource_server(pds) let assert Ok(response) = httpc.send(request) let assert Ok(resource_server) = json.parse(response.body, oauth.resource_server_decoder())
let assert Ok(request) = oauth.query_authorisation_server(resource_server.authorisation_server) let assert Ok(response) = httpc.send(request) let assert Ok(authorisation_server) = json.parse(response.body, { oauth.authorisation_server_decoder(resource_server.authorisation_server) })
// 2. Construct a new OAuth client with the scopes we want to request. Localhost // clients are validated slightly differently so we provide a dedicated function // to construct them. let client = oauth.localhost_client(1234, "/") |> oauth.client_scopes(["repo:app.bsky.feed.post?action=create"])
// 3. Set up a quick and dirty callback server to (hopefully) receive an // authorisation grant once the user has completed the authorisation flow in // their browser. let subject = process.new_subject() let assert Ok(_) = ewe.new(fn(request) { case oauth.handle_authorisation_callback(request, client) { Ok(authorisation_grant) -> { process.send(subject, authorisation_grant)
response.new(200) |> response.set_body(ewe.TextData( "OAuth callback received. You can close this tab.", )) }
Error(_) -> response.new(400) |> response.set_body(ewe.TextData("Invalid OAuth callback")) } }) |> ewe.bind(interface: "127.0.0.1") |> ewe.listening(port: 1234) |> ewe.quiet |> ewe.start
// 4. Generating a signing key and a derived verification key for DPoP proof // signing and verification. Every request we send from now will include a signed // DPoP proof and the authorisation server will verify it using provided verification // key. // // It is a strict requirement that ES256 is used for DPoP proofs. let sign_key = ywt.generate_key(algorithm.es256) let verify_key = verify_key.derived(sign_key)
// 5. Initiate a pushed authorisation request to the server. Building this // request gives us two extra things we need to hold on to for later: // // - A PKCE code verifier that will be used to exchange the authorisation code // for a session later on. // // - A random authorisation state that we will use to verify that a received // authorisation grant is for the request we initiated. let #(request, code_verifier, authorisation_state) = oauth.request_authorisation( client, authorisation_server, Some(did.to_string(hayleigh)), verify_key, None, )
// 6. Send the request to the authorisation server and decode the authorisation // token. This lets us construct the redirect URL we can pass to the user. In // a typical app this might be a real redirect, but for this example we just // print it and navigate manually. // // From now on every single request will need to include an opaque nonce that // the server provides in every response. The first time we send a request // we don't have that nonce yet so failure is expected: the `send_with_retry` // util handles this. It also handles cases where the nonce rotates and a // retry is required. let assert Ok(#(response, nonce)) = send_with_retry(request, None, sign_key) let assert Ok(authorisation_token) = json.parse(response.body, oauth.authorisation_token_decoder())
let redirect = oauth.redirect(client, authorisation_server, authorisation_token)
io.println("Open this URL to continue OAuth:") io.println(uri.to_string(redirect))
// 7. Wait forever until the we complete the authorisation flow in the browser // and the callback server we set up receives the authorisation grant. let authorisation_grant = process.receive_forever(subject)
// 8. The grant we got back is currently unverified. In order to use it we need // to verify that it came from the authorisation server we expect and that it // caries that same authorisation state that sent in the initial request. let assert Ok(authorisation_grant) = oauth.verify_authorisation_grant( authorisation_grant, authorisation_server, authorisation_state, )
// 9. Now we're ready to create the OAuth session. We exhcnage the authorisation // grant for a longer-lived session that we can use to make requests to the // resource server (aka PDS). // // We provide the code verifier from earlier here to prove that we are the same // client that initiated the authorisation request. let builder = oauth.create_session( client, authorisation_server, authorisation_grant, code_verifier, verify_key, nonce, ) let assert Ok(#(response, nonce)) = send_with_retry(builder, None, sign_key)
// 10. Once we have the OAuth session we must verify that the subject of the // session matches the account we wanted to authenticate as. The xrpc client in // this package will only accept verified sessions so this step cannot be skipped. let assert Ok(session) = json.parse(response.body, oauth.session_decoder()) let assert Ok(session) = oauth.verify_session_subject(session, hayleigh)
echo #(session, nonce)
Nil}
/// Because of the "sans-io" nature of this library, we can't provide or abstract/// this function for you. It handles two crucial things and I'd expect applications/// to implement or copy something like this themselves:/// /// 1. It signs the DPoP proof for the request using the provided signing key./// This can't be abstracted as key signing on the JavaScript target is async and/// therefor wrapped in a `Promise`./// /// 2. It handles the nonce rotation and retry logic that is an expected part of/// DPoP request handling./// fn send_with_retry( builder: DpopRequest(String), session: Option(Session(Verified)), sign_key: SignKey,) -> Result(#(Response(String), oauth.Nonce), httpc.HttpError) { // sign the dpop proof and construct the request object let signature = ywt.sign_string(oauth.proof(builder), sign_key) let request = oauth.prepare(builder, signature)
// send the request use response <- result.try(httpc.send(request))
// extract the nonce and also determine if a retry is required case oauth.extract_nonce(response) { // if things are good pass the nonce of so it can be // used in subsequent requests... Ok(oauth.Continue(nonce)) -> Ok(#(response, nonce))
// ... otherwise if the nonce was rotated or was missing, retry Ok(oauth.Retry(nonce)) -> builder |> oauth.retry(nonce, session) |> send_with_retry(session, sign_key)
// ... else make some error who cares Error(_) -> panic }}