From a7410807e2e20f69e9eedb5f0521ccc8d5390bdb Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Tue, 21 Jul 2026 15:58:16 -0400 Subject: [PATCH] oauth: add token refresh and dpop nonce handling --- src/atproto_records.gleam | 61 +++++++++++---- src/entwine.gleam | 15 +++- src/oauth.gleam | 152 +++++++++++++++++++++++++++++++++----- src/tangled.gleam | 2 + 4 files changed, 196 insertions(+), 34 deletions(-) diff --git a/src/atproto_records.gleam b/src/atproto_records.gleam index 17ab0bb..d3ee107 100644 --- a/src/atproto_records.gleam +++ b/src/atproto_records.gleam @@ -21,10 +21,12 @@ pub fn create_record( account_pds: String, collection_name: String, record: List(#(String, json.Json)), + client: oauth.OAuthClient, credential: oauth.OAuthCredential, ) -> Result(oauth.OAuthCredential, CreateRecordError) { + let credential = oauth.refresh_expired_access_token(client, credential) let assert Ok(collection) = nsid.parse(collection_name) - let request = + let record_request = possum.create_record( account_did, pds: account_pds, @@ -35,37 +37,70 @@ pub fn create_record( validate: option.None, ) let request_url = - request + record_request |> request.to_uri |> uri.to_string + + send_record_request(record_request, request_url, credential, True) +} + +fn send_record_request( + record_request: request.Request(String), + request_url: String, + credential: oauth.OAuthCredential, + should_retry: Bool, +) -> Result(oauth.OAuthCredential, CreateRecordError) { let dpop_proof = oauth.create_dpop_proof( credential.dpop_key, "POST", request_url, - credential.dpop_nonce, + credential.resource_server_nonce, option.Some(credential.access_token), ) - let request = - request + let authenticated_request = + record_request |> request.set_header("authorization", "DPoP " <> credential.access_token) |> request.set_header("dpop", dpop_proof) - case httpc.send(request) { + case httpc.send(authenticated_request) { Error(_) -> Error(RequestFailed) - Ok(response) if response.status == 200 -> { - let dpop_nonce = + Ok(response) if response.status == 200 -> + Ok(store_resource_server_nonce(credential, response)) + + Ok(response) if response.status == 401 && should_retry -> { + let resource_server_nonce = response.get_header(response, "dpop-nonce") |> option.from_result - let credential = case dpop_nonce { - option.Some(_) -> oauth.with_dpop_nonce(credential, dpop_nonce) - option.None -> credential - } - Ok(credential) + case resource_server_nonce { + option.Some(_) -> + send_record_request( + record_request, + request_url, + oauth.with_resource_server_nonce(credential, resource_server_nonce), + False, + ) + option.None -> Error(UnexpectedStatus(response.status)) + } } Ok(response) -> Error(UnexpectedStatus(response.status)) } } + +fn store_resource_server_nonce( + credential: oauth.OAuthCredential, + response: response.Response(String), +) -> oauth.OAuthCredential { + let resource_server_nonce = + response.get_header(response, "dpop-nonce") + |> option.from_result + + case resource_server_nonce { + option.Some(_) -> + oauth.with_resource_server_nonce(credential, resource_server_nonce) + option.None -> credential + } +} diff --git a/src/entwine.gleam b/src/entwine.gleam index e9f06c7..25e9962 100644 --- a/src/entwine.gleam +++ b/src/entwine.gleam @@ -101,6 +101,7 @@ fn collect_tangled_profiles( fn create_tangled_follows( account_did: at_did.Did, account_pds: String, + client: oauth.OAuthClient, credential: oauth.OAuthCredential, follows: List(bsky.Follow), ) -> oauth.OAuthCredential { @@ -112,6 +113,7 @@ fn create_tangled_follows( tangled.create_tangled_follow( account_did, account_pds, + client, credential, follow.subject, ) @@ -127,7 +129,13 @@ fn create_tangled_follows( } } - create_tangled_follows(account_did, account_pds, credential, remaining) + create_tangled_follows( + account_did, + account_pds, + client, + credential, + remaining, + ) } } } @@ -140,8 +148,9 @@ pub fn main() -> Nil { io.println("Resolved to " <> at_did.to_string(did) <> " on " <> pds <> ".") io.println("Open the authorization URL in your browser to continue.") + let client = oauth_client() let credential = - oauth.authenticate("https://" <> pds, at_did.to_string(did), oauth_client()) + oauth.authenticate("https://" <> pds, at_did.to_string(did), client) io.println("Authenticated successfully as " <> credential.subject <> ".") let bsky_follows = bsky.get_bsky_follows(did, pds) @@ -176,7 +185,7 @@ pub fn main() -> Nil { case answer { "y" -> { let _credential = - create_tangled_follows(did, pds, credential, bsky_only_follows) + create_tangled_follows(did, pds, client, credential, bsky_only_follows) Nil } diff --git a/src/oauth.gleam b/src/oauth.gleam index 2cc7c6b..c2bd558 100644 --- a/src/oauth.gleam +++ b/src/oauth.gleam @@ -43,12 +43,16 @@ pub type OAuthSession { token_endpoint: String, pkce_verifier: String, dpop_key: gose.Key(String), - dpop_nonce: Option(String), + authorization_server_nonce: Option(String), ) } pub type ParResponse { - ParResponse(request_uri: String, expires_in: Int, dpop_nonce: Option(String)) + ParResponse( + request_uri: String, + expires_in: Int, + authorization_server_nonce: Option(String), + ) } pub type TokenResponse { @@ -59,7 +63,7 @@ pub type TokenResponse { refresh_token: Option(String), scope: String, sub: String, - dpop_nonce: Option(String), + authorization_server_nonce: Option(String), ) } @@ -70,17 +74,29 @@ pub type OAuthClient { pub type OAuthCredential { OAuthCredential( access_token: String, + refresh_token: Option(String), + expires_at: Int, + token_endpoint: String, subject: String, dpop_key: gose.Key(String), - dpop_nonce: Option(String), + resource_server_nonce: Option(String), + authorization_server_nonce: Option(String), ) } -pub fn with_dpop_nonce( +pub fn with_resource_server_nonce( credential: OAuthCredential, - dpop_nonce: Option(String), + resource_server_nonce: Option(String), ) -> OAuthCredential { - OAuthCredential(..credential, dpop_nonce:) + OAuthCredential(..credential, resource_server_nonce:) +} + +fn current_unix_seconds() -> Int { + let #(seconds, _) = + timestamp.system_time() + |> timestamp.to_unix_seconds_and_nanoseconds() + + seconds } fn random_string() -> String { @@ -276,11 +292,11 @@ pub fn push_authorization_request( let assert Ok(#(request_uri, expires_in)) = json.parse(par_response.body, par_response_decoder) - let dpop_nonce = + let authorization_server_nonce = response.get_header(par_response, "dpop-nonce") |> option.from_result - ParResponse(request_uri:, expires_in:, dpop_nonce:) + ParResponse(request_uri:, expires_in:, authorization_server_nonce:) } pub fn build_authorization_url( @@ -332,10 +348,16 @@ pub fn exchange_code_for_tokens( code: String, pkce_verifier: String, dpop_key: gose.Key(String), - dpop_nonce: Option(String), + authorization_server_nonce: Option(String), ) -> TokenResponse { let dpop_proof = - create_dpop_proof(dpop_key, "POST", token_endpoint, dpop_nonce, option.None) + create_dpop_proof( + dpop_key, + "POST", + token_endpoint, + authorization_server_nonce, + option.None, + ) let form_body = uri.query_to_string([ @@ -359,10 +381,101 @@ pub fn exchange_code_for_tokens( let assert Ok(token_response) = httpc.send(token_request) - let dpop_nonce = + let authorization_server_nonce = response.get_header(token_response, "dpop-nonce") |> option.from_result + parse_token_response(token_response.body, authorization_server_nonce) +} + +const expiry_leeway_seconds = 60 + +pub fn refresh_expired_access_token( + client: OAuthClient, + credential: OAuthCredential, +) -> OAuthCredential { + case current_unix_seconds() + expiry_leeway_seconds >= credential.expires_at { + True -> refresh_access_token(client, credential) + False -> credential + } +} + +pub fn refresh_access_token( + client: OAuthClient, + credential: OAuthCredential, +) -> OAuthCredential { + let OAuthClient(client_id:, ..) = client + let assert option.Some(refresh_token) = credential.refresh_token + let form_body = + uri.query_to_string([ + #("grant_type", "refresh_token"), + #("client_id", client_id), + #("refresh_token", refresh_token), + ]) + let assert Ok(token_uri) = uri.parse(credential.token_endpoint) + let assert Ok(token_request) = request.from_uri(token_uri) + let token_request = + request.Request(..token_request, method: http.Post, body: form_body) + |> request.set_header("content-type", "application/x-www-form-urlencoded") + + let #(token_response, authorization_server_nonce) = + send_refresh_request(token_request, credential, True) + let tokens = + parse_token_response(token_response.body, authorization_server_nonce) + + let assert True = tokens.token_type == "DPoP" + let assert True = tokens.sub == credential.subject + let refresh_token = case tokens.refresh_token { + option.Some(_) -> tokens.refresh_token + option.None -> credential.refresh_token + } + + OAuthCredential( + ..credential, + access_token: tokens.access_token, + refresh_token:, + expires_at: current_unix_seconds() + tokens.expires_in, + authorization_server_nonce:, + ) +} + +fn send_refresh_request( + token_request: request.Request(String), + credential: OAuthCredential, + should_retry: Bool, +) -> #(response.Response(String), Option(String)) { + let dpop_proof = + create_dpop_proof( + credential.dpop_key, + "POST", + credential.token_endpoint, + credential.authorization_server_nonce, + option.None, + ) + let assert Ok(token_response) = + token_request + |> request.set_header("dpop", dpop_proof) + |> httpc.send + + let authorization_server_nonce = + response.get_header(token_response, "dpop-nonce") + |> option.from_result + + case token_response.status, authorization_server_nonce, should_retry { + 400, option.Some(_), True -> + send_refresh_request( + token_request, + OAuthCredential(..credential, authorization_server_nonce:), + False, + ) + _, _, _ -> #(token_response, authorization_server_nonce) + } +} + +fn parse_token_response( + body: String, + authorization_server_nonce: Option(String), +) -> TokenResponse { let token_response_decoder = { use access_token <- decode.field("access_token", decode.string) use token_type <- decode.field("token_type", decode.string) @@ -382,12 +495,11 @@ pub fn exchange_code_for_tokens( refresh_token:, scope:, sub:, - dpop_nonce:, + authorization_server_nonce:, )) } - let assert Ok(tokens) = - json.parse(token_response.body, token_response_decoder) + let assert Ok(tokens) = json.parse(body, token_response_decoder) tokens } @@ -422,7 +534,7 @@ pub fn authenticate( token_endpoint: metadata.token_endpoint, pkce_verifier:, dpop_key:, - dpop_nonce: par_response.dpop_nonce, + authorization_server_nonce: par_response.authorization_server_nonce, ) let sessions = save_oauth_session(sessions, session) @@ -450,7 +562,7 @@ pub fn authenticate( code, session.pkce_verifier, session.dpop_key, - session.dpop_nonce, + session.authorization_server_nonce, ) let assert True = tokens.token_type == "DPoP" let assert True = tokens.sub == expected_subject @@ -463,8 +575,12 @@ pub fn authenticate( OAuthCredential( access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_at: current_unix_seconds() + tokens.expires_in, + token_endpoint: session.token_endpoint, subject: tokens.sub, dpop_key: session.dpop_key, - dpop_nonce: tokens.dpop_nonce, + resource_server_nonce: option.None, + authorization_server_nonce: tokens.authorization_server_nonce, ) } diff --git a/src/tangled.gleam b/src/tangled.gleam index 563cfd4..0dd000c 100644 --- a/src/tangled.gleam +++ b/src/tangled.gleam @@ -41,6 +41,7 @@ pub fn create_follow_error_message(error: CreateFollowError) -> String { pub fn create_tangled_follow( account_did: did.Did, account_pds: String, + client: oauth.OAuthClient, credential: oauth.OAuthCredential, subject: did.Did, ) -> Result(oauth.OAuthCredential, CreateFollowError) { @@ -56,6 +57,7 @@ pub fn create_tangled_follow( account_pds, "sh.tangled.graph.follow", record, + client, credential, ) { -- 2.51.2