Find your Bluesky friends on Tangled!

OAuth sign-in crashes on the PAR step: Bluesky requires a DPoP nonce and the client never retries #1

Open katsuricata.com
Open opened by katsuricata.com

What happens #

Signing in with any account hosted on the bsky.network PDSs fails every time. The flow dies inside push_authorization_request before the authorization URL is even printed:

$ gleam run
Atmosphere handle: katsuricata.com
Resolved to did:plc:b2oydp42a7jhgj72nbykc4ej on magic.us-west.host.bsky.network.
Open the authorization URL in your browser to continue.
runtime error: let assert

Pattern match failed, no pattern matched the value.

unmatched value:
  Error(UnableToDecode([DecodeError("Field", "Nothing", ["request_uri"]), DecodeError("Field", "Nothing", ["expires_in"])]))

stacktrace:
  oauth.push_authorization_request src/oauth.gleam:335
  oauth.authenticate src/oauth.gleam:564
  entwine.main src/entwine.gleam:152

The decode error is misleading. The PAR response never had a chance to contain request_uri or expires_in, because the server rejected the request.

Why it happens #

push_authorization_request builds its DPoP proof with no nonce claim and sends it exactly once. Bluesky's authorization server enforces DPoP nonces immediately on the PAR endpoint, so the first request always comes back as a 400. I echoed the raw response; this is what's actually being returned:

HTTP 400
dpop-nonce: vabyUi4VGOqlMG1QrY__AUv8XTNqx3Fi6WemgXeWyzc
content-type: application/json

{"error":"use_dpop_nonce","error_description":"Authorization server requires nonce in DPoP proof"}

So the client is expected to take the dpop-nonce header, rebuild the proof with that nonce, and retry, per RFC 9449 and the atproto OAuth profile. push_authorization_request doesn't do any of that; it just asserts the error body decodes as a success payload, which is where the crash comes from.

Funnily enough, the codebase already knows this dance. send_token_request handles the exact same 400 + use_dpop_nonce case for the token endpoint, retrying once with the fresh nonce. The PAR endpoint just never got the same treatment.

How to reproduce #

  1. gleam run
  2. Enter any Bluesky handle (anything that resolves to a *.host.bsky.network PDS)
  3. Crash, every time

In other words: the Bluesky login path hasn't been able to work since bsky.social started requiring DPoP nonces at PAR.

Fix that worked for me #

I mirrored the retry logic from send_token_request into a new send_par_request helper and let push_authorization_request call it. First attempt goes out without a nonce; on the use_dpop_nonce 400 it retries once with the nonce from the response header, building a fresh proof (so the jti differs too). Full sign-in flow then works end to end against bsky.social; I've used it to complete a real run of the whole tool (friend handles in this output redacted by me):

$ gleam run
Atmosphere handle: katsuricata.com
Resolved to did:plc:b2oydp42a7jhgj72nbykc4ej on magic.us-west.host.bsky.network.
Open the authorization URL in your browser to continue.
Listening on http://127.0.0.1:8080
https://bsky.social/oauth/authorize?client_id=http%3A%2F%2Flocalhost%3F...(snipped)
Authenticated successfully as did:plc:b2oydp42a7jhgj72nbykc4ej.
156 Bluesky follow(s) found.
3 Tangled follow(s) found.
2 accounts to follow on Tangled:
[redacted handle 1] (did:plc:[redacted])
[redacted handle 2] (did:plc:[redacted])
Follow on Tangled? (y/n) y
Follow created
Follow created
--- a/src/oauth.gleam
+++ b/src/oauth.gleam
@@ -297,9 +297,6 @@ pub fn push_authorization_request(
   login_hint: String,
   dpop_key: gose.Key(String),
 ) -> ParResponse {
-  let dpop_proof =
-    create_dpop_proof(dpop_key, "POST", par_endpoint, option.None, option.None)
-
   let form_body =
     uri.query_to_string([
       #("client_id", client_id),
@@ -317,13 +314,10 @@ pub fn push_authorization_request(
 
   let par_request =
     request.Request(..par_request, method: http.Post, body: form_body)
-
-  let par_request =
-    par_request
     |> request.set_header("content-type", "application/x-www-form-urlencoded")
-    |> request.set_header("dpop", dpop_proof)
 
-  let assert Ok(par_response) = httpc.send(par_request)
+  let #(par_response, authorization_server_nonce) =
+    send_par_request(par_request, par_endpoint, dpop_key, option.None, True)
 
   let par_response_decoder = {
     use request_uri <- decode.field("request_uri", decode.string)
@@ -335,11 +329,45 @@ pub fn push_authorization_request(
   let assert Ok(#(request_uri, expires_in)) =
     json.parse(par_response.body, par_response_decoder)
 
-  let authorization_server_nonce = expect_dpop_nonce(par_response)
-
   ParResponse(request_uri:, expires_in:, authorization_server_nonce:)
 }
 
+// The atproto OAuth profile requires authorization servers to reject DPoP
+// proofs that omit an up-to-date nonce, so be prepared to retry once.
+fn send_par_request(
+  par_request: request.Request(String),
+  par_endpoint: String,
+  dpop_key: gose.Key(String),
+  nonce: Option(String),
+  should_retry: Bool,
+) -> #(response.Response(String), String) {
+  let dpop_proof =
+    create_dpop_proof(dpop_key, "POST", par_endpoint, nonce, option.None)
+
+  let assert Ok(par_response) =
+    par_request
+    |> request.set_header("dpop", dpop_proof)
+    |> httpc.send
+
+  let nonce = expect_dpop_nonce(par_response)
+
+  case
+    par_response.status,
+    is_use_dpop_nonce_error(par_response.body),
+    should_retry
+  {
+    400, True, True ->
+      send_par_request(
+        par_request,
+        par_endpoint,
+        dpop_key,
+        option.Some(nonce),
+        False,
+      )
+    _, _, _ -> #(par_response, nonce)
+  }
+}
+

Not attached to this exact shape; if you'd rather unify the PAR and token retry paths into one helper, that also works.

One adjacent suggestion: when the PAR response is an OAuth error body like this one, the let assert Ok(...) decode turns it into a confusing pattern-match crash. Matching on the response status before decoding (or decoding error/error_description on failure) would make the next breakage like this a lot easier to spot from a bug report.

Environment #

  • gleam 1.18.1
  • Erlang/OTP 29 (erts-17.0.1)
  • kryptos 1.4.0, gose 2.1.0, gleam_httpc 5.0.0
  • CachyOS Linux (Arch family)
Sign up or Sign in to add to the discussion.