From c687a2a2b1cb44d52f9a74795d7ff77411647576 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Tue, 21 Jul 2026 16:34:52 -0400 Subject: [PATCH] oauth: scope dpop nonces to origin, verify handle bidirectionally, handle missing nonces --- src/atproto.gleam | 10 +++ src/atproto_records.gleam | 53 ++++++----- src/entwine.gleam | 7 +- src/oauth.gleam | 182 +++++++++++++++++++++++++------------- src/tangled.gleam | 4 + test/entwine_test.gleam | 22 +++++ 6 files changed, 190 insertions(+), 88 deletions(-) diff --git a/src/atproto.gleam b/src/atproto.gleam index 48b22e2..ca5002a 100644 --- a/src/atproto.gleam +++ b/src/atproto.gleam @@ -1,7 +1,9 @@ import gleam/dynamic/decode import gleam/httpc import gleam/json +import gleam/list import gleam/option +import gleam/string import gleam/uri import possum import possum/did @@ -39,6 +41,14 @@ pub fn get_did(handl: String) -> did.Did { let assert Ok(did_string) = json.parse(resolve_response.body, did_decoder) let assert Ok(account_did) = did.parse(did_string) + + // Bidirectionally verify the handle: the DID document must claim it back. + let plc_request = possum.get_plc_data(account_did) + let assert Ok(plc_response) = httpc.send(plc_request) + let aliases_decoder = decode.at(["alsoKnownAs"], decode.list(decode.string)) + let assert Ok(aliases) = json.parse(plc_response.body, aliases_decoder) + let assert True = list.contains(aliases, "at://" <> string.lowercase(handl)) + account_did } diff --git a/src/atproto_records.gleam b/src/atproto_records.gleam index d3ee107..f7118aa 100644 --- a/src/atproto_records.gleam +++ b/src/atproto_records.gleam @@ -14,6 +14,7 @@ import oauth pub type CreateRecordError { RequestFailed UnexpectedStatus(Int) + MissingDpopNonce } pub fn create_record( @@ -55,7 +56,10 @@ fn send_record_request( credential.dpop_key, "POST", request_url, - credential.resource_server_nonce, + oauth.dpop_nonce_for_request( + credential.resource_server_nonces, + request_url, + ), option.Some(credential.access_token), ) let authenticated_request = @@ -67,40 +71,35 @@ fn send_record_request( Error(_) -> Error(RequestFailed) 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 + case response.get_header(response, "dpop-nonce") { + Ok(resource_server_nonce) -> + Ok(oauth.with_resource_server_nonce( + credential, + request_url, + resource_server_nonce, + )) + Error(_) -> Error(MissingDpopNonce) + } - case resource_server_nonce { - option.Some(_) -> + // DPoP nonce errors surface as 401 or 400 depending on the server. + Ok(response) + if should_retry && { response.status == 401 || response.status == 400 } + -> + case response.get_header(response, "dpop-nonce") { + Ok(resource_server_nonce) -> send_record_request( record_request, request_url, - oauth.with_resource_server_nonce(credential, resource_server_nonce), + oauth.with_resource_server_nonce( + credential, + request_url, + resource_server_nonce, + ), False, ) - option.None -> Error(UnexpectedStatus(response.status)) + Error(_) -> Error(MissingDpopNonce) } - } 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 25e9962..f62033d 100644 --- a/src/entwine.gleam +++ b/src/entwine.gleam @@ -150,7 +150,12 @@ pub fn main() -> Nil { let client = oauth_client() let credential = - oauth.authenticate("https://" <> pds, at_did.to_string(did), client) + oauth.authenticate( + "https://" <> pds, + at_did.to_string(did), + raw_handle, + client, + ) io.println("Authenticated successfully as " <> credential.subject <> ".") let bsky_follows = bsky.get_bsky_follows(did, pds) diff --git a/src/oauth.gleam b/src/oauth.gleam index c2bd558..1221800 100644 --- a/src/oauth.gleam +++ b/src/oauth.gleam @@ -43,7 +43,7 @@ pub type OAuthSession { token_endpoint: String, pkce_verifier: String, dpop_key: gose.Key(String), - authorization_server_nonce: Option(String), + authorization_server_nonces: dict.Dict(String, String), ) } @@ -51,7 +51,7 @@ pub type ParResponse { ParResponse( request_uri: String, expires_in: Int, - authorization_server_nonce: Option(String), + authorization_server_nonce: String, ) } @@ -63,7 +63,6 @@ pub type TokenResponse { refresh_token: Option(String), scope: String, sub: String, - authorization_server_nonce: Option(String), ) } @@ -79,16 +78,53 @@ pub type OAuthCredential { token_endpoint: String, subject: String, dpop_key: gose.Key(String), - resource_server_nonce: Option(String), - authorization_server_nonce: Option(String), + resource_server_nonces: dict.Dict(String, String), + authorization_server_nonces: dict.Dict(String, String), ) } pub fn with_resource_server_nonce( credential: OAuthCredential, - resource_server_nonce: Option(String), + request_url: String, + resource_server_nonce: String, ) -> OAuthCredential { - OAuthCredential(..credential, resource_server_nonce:) + let resource_server_nonces = + store_dpop_nonce( + credential.resource_server_nonces, + request_url, + resource_server_nonce, + ) + + OAuthCredential(..credential, resource_server_nonces:) +} + +pub fn dpop_nonce_for_request( + nonces: dict.Dict(String, String), + request_url: String, +) -> Option(String) { + dict.get(nonces, request_origin(request_url)) + |> option.from_result +} + +pub fn store_dpop_nonce( + nonces: dict.Dict(String, String), + request_url: String, + dpop_nonce: String, +) -> dict.Dict(String, String) { + dict.insert(nonces, request_origin(request_url), dpop_nonce) +} + +// The atproto OAuth profile requires servers to send a DPoP-Nonce header +// with every response to a DPoP-bound request; reject responses that omit it. +fn expect_dpop_nonce(server_response: response.Response(String)) -> String { + let assert Ok(nonce) = response.get_header(server_response, "dpop-nonce") + nonce +} + +fn request_origin(request_url: String) -> String { + let assert Ok(request_uri) = uri.parse(request_url) + let assert Ok(origin) = uri.origin(request_uri) + origin } fn current_unix_seconds() -> Int { @@ -162,6 +198,11 @@ pub fn get_oauth_server_metadata(auth_server: String) -> OAuthServerMetadata { } let assert Ok(metadata) = json.parse(metadata_response.body, metadata_decoder) + + // The issuer must be the origin of the URL the metadata was fetched from. + let assert True = + request_origin(metadata.issuer) == request_origin(auth_server) + metadata } @@ -253,6 +294,7 @@ pub fn push_authorization_request( scope: String, state: String, code_challenge: String, + login_hint: String, dpop_key: gose.Key(String), ) -> ParResponse { let dpop_proof = @@ -267,6 +309,7 @@ pub fn push_authorization_request( #("state", state), #("code_challenge", code_challenge), #("code_challenge_method", "S256"), + #("login_hint", login_hint), ]) let assert Ok(par_uri) = uri.parse(par_endpoint) @@ -292,9 +335,7 @@ pub fn push_authorization_request( let assert Ok(#(request_uri, expires_in)) = json.parse(par_response.body, par_response_decoder) - let authorization_server_nonce = - response.get_header(par_response, "dpop-nonce") - |> option.from_result + let authorization_server_nonce = expect_dpop_nonce(par_response) ParResponse(request_uri:, expires_in:, authorization_server_nonce:) } @@ -348,17 +389,8 @@ pub fn exchange_code_for_tokens( code: String, pkce_verifier: String, dpop_key: gose.Key(String), - authorization_server_nonce: Option(String), -) -> TokenResponse { - let dpop_proof = - create_dpop_proof( - dpop_key, - "POST", - token_endpoint, - authorization_server_nonce, - option.None, - ) - + authorization_server_nonces: dict.Dict(String, String), +) -> #(TokenResponse, dict.Dict(String, String)) { let form_body = uri.query_to_string([ #("grant_type", "authorization_code"), @@ -373,19 +405,18 @@ pub fn exchange_code_for_tokens( let token_request = request.Request(..token_request, method: http.Post, body: form_body) - - let token_request = - token_request |> request.set_header("content-type", "application/x-www-form-urlencoded") - |> request.set_header("dpop", dpop_proof) - - let assert Ok(token_response) = httpc.send(token_request) - let authorization_server_nonce = - response.get_header(token_response, "dpop-nonce") - |> option.from_result + let #(token_response, authorization_server_nonces) = + send_token_request( + token_request, + token_endpoint, + dpop_key, + authorization_server_nonces, + True, + ) - parse_token_response(token_response.body, authorization_server_nonce) + #(parse_token_response(token_response.body), authorization_server_nonces) } const expiry_leeway_seconds = 60 @@ -418,10 +449,15 @@ pub fn refresh_access_token( 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 #(token_response, authorization_server_nonces) = + send_token_request( + token_request, + credential.token_endpoint, + credential.dpop_key, + credential.authorization_server_nonces, + True, + ) + let tokens = parse_token_response(token_response.body) let assert True = tokens.token_type == "DPoP" let assert True = tokens.sub == credential.subject @@ -435,21 +471,23 @@ pub fn refresh_access_token( access_token: tokens.access_token, refresh_token:, expires_at: current_unix_seconds() + tokens.expires_in, - authorization_server_nonce:, + authorization_server_nonces:, ) } -fn send_refresh_request( +fn send_token_request( token_request: request.Request(String), - credential: OAuthCredential, + token_endpoint: String, + dpop_key: gose.Key(String), + authorization_server_nonces: dict.Dict(String, String), should_retry: Bool, -) -> #(response.Response(String), Option(String)) { +) -> #(response.Response(String), dict.Dict(String, String)) { let dpop_proof = create_dpop_proof( - credential.dpop_key, + dpop_key, "POST", - credential.token_endpoint, - credential.authorization_server_nonce, + token_endpoint, + dpop_nonce_for_request(authorization_server_nonces, token_endpoint), option.None, ) let assert Ok(token_response) = @@ -457,25 +495,36 @@ fn send_refresh_request( |> request.set_header("dpop", dpop_proof) |> httpc.send - let authorization_server_nonce = - response.get_header(token_response, "dpop-nonce") - |> option.from_result + let received_nonce = expect_dpop_nonce(token_response) + let authorization_server_nonces = + store_dpop_nonce( + authorization_server_nonces, + token_endpoint, + received_nonce, + ) - case token_response.status, authorization_server_nonce, should_retry { - 400, option.Some(_), True -> - send_refresh_request( + case + token_response.status, + is_use_dpop_nonce_error(token_response.body), + should_retry + { + 400, True, True -> + send_token_request( token_request, - OAuthCredential(..credential, authorization_server_nonce:), + token_endpoint, + dpop_key, + authorization_server_nonces, False, ) - _, _, _ -> #(token_response, authorization_server_nonce) + _, _, _ -> #(token_response, authorization_server_nonces) } } -fn parse_token_response( - body: String, - authorization_server_nonce: Option(String), -) -> TokenResponse { +fn is_use_dpop_nonce_error(body: String) -> Bool { + json.parse(body, decode.at(["error"], decode.string)) == Ok("use_dpop_nonce") +} + +fn parse_token_response(body: String) -> TokenResponse { let token_response_decoder = { use access_token <- decode.field("access_token", decode.string) use token_type <- decode.field("token_type", decode.string) @@ -495,7 +544,6 @@ fn parse_token_response( refresh_token:, scope:, sub:, - authorization_server_nonce:, )) } @@ -506,6 +554,7 @@ fn parse_token_response( pub fn authenticate( pds: String, expected_subject: String, + login_hint: String, client: OAuthClient, ) -> OAuthCredential { let OAuthClient(client_id:, redirect_uri:, scope:) = client @@ -524,9 +573,22 @@ pub fn authenticate( scope, state, pkce_challenge, + login_hint, dpop_key, ) + // An authorization server may serve its PAR and token endpoints from + // different origins (e.g. behind a gateway), so seed the nonce under both. + let authorization_server_nonces = + dict.new() + |> store_dpop_nonce( + metadata.pushed_authorization_request_endpoint, + par_response.authorization_server_nonce, + ) + |> store_dpop_nonce( + metadata.token_endpoint, + par_response.authorization_server_nonce, + ) let session = OAuthSession( state:, @@ -534,7 +596,7 @@ pub fn authenticate( token_endpoint: metadata.token_endpoint, pkce_verifier:, dpop_key:, - authorization_server_nonce: par_response.authorization_server_nonce, + authorization_server_nonces:, ) let sessions = save_oauth_session(sessions, session) @@ -554,7 +616,7 @@ pub fn authenticate( let assert option.Some(session) = get_oauth_session(sessions, returned_state) let assert True = issuer == session.issuer - let tokens = + let #(tokens, authorization_server_nonces) = exchange_code_for_tokens( session.token_endpoint, client_id, @@ -562,7 +624,7 @@ pub fn authenticate( code, session.pkce_verifier, session.dpop_key, - session.authorization_server_nonce, + session.authorization_server_nonces, ) let assert True = tokens.token_type == "DPoP" let assert True = tokens.sub == expected_subject @@ -580,7 +642,7 @@ pub fn authenticate( token_endpoint: session.token_endpoint, subject: tokens.sub, dpop_key: session.dpop_key, - resource_server_nonce: option.None, - authorization_server_nonce: tokens.authorization_server_nonce, + resource_server_nonces: dict.new(), + authorization_server_nonces:, ) } diff --git a/src/tangled.gleam b/src/tangled.gleam index 0dd000c..92008bc 100644 --- a/src/tangled.gleam +++ b/src/tangled.gleam @@ -27,6 +27,7 @@ pub type FollowPage { pub type CreateFollowError { RequestFailed UnexpectedStatus(Int) + MissingDpopNonce } pub fn create_follow_error_message(error: CreateFollowError) -> String { @@ -35,6 +36,8 @@ pub fn create_follow_error_message(error: CreateFollowError) -> String { UnexpectedStatus(status) -> "Follow request failed with HTTP status " <> int.to_string(status) + + MissingDpopNonce -> "Follow response did not include a DPoP nonce" } } @@ -65,6 +68,7 @@ pub fn create_tangled_follow( Error(atproto_records.RequestFailed) -> Error(RequestFailed) Error(atproto_records.UnexpectedStatus(status)) -> Error(UnexpectedStatus(status)) + Error(atproto_records.MissingDpopNonce) -> Error(MissingDpopNonce) } } diff --git a/test/entwine_test.gleam b/test/entwine_test.gleam index b5a7a65..483de3b 100644 --- a/test/entwine_test.gleam +++ b/test/entwine_test.gleam @@ -1,6 +1,8 @@ import bsky import entwine import gleam/bit_array +import gleam/dict +import gleam/option import gleeunit import kryptos/hash import oauth @@ -47,6 +49,26 @@ pub fn create_pkce_returns_matching_challenge_test() { assert challenge == expected_challenge } +pub fn dpop_nonces_are_scoped_to_their_origin_test() { + let nonces = + oauth.store_dpop_nonce( + dict.new(), + "https://pds.cute.haus/xrpc/com.atproto.repo.createRecord", + "pds-nonce", + ) + + assert oauth.dpop_nonce_for_request( + nonces, + "https://pds.cute.haus/xrpc/com.atproto.repo.getRecord", + ) + == option.Some("pds-nonce") + assert oauth.dpop_nonce_for_request( + nonces, + "https://auth.cute.haus/oauth/token", + ) + == option.None +} + pub fn find_accounts_to_follow_excludes_existing_tangled_follows_test() { let alice = parse_did("did:plc:alice") let bob = parse_did("did:plc:bob") -- 2.51.2