Skip to content

Comments

feat: add dynamic authorization support via callback provider#194

Open
EddieHouston wants to merge 1 commit intobitcoindevkit:masterfrom
EddieHouston:rpc-auth
Open

feat: add dynamic authorization support via callback provider#194
EddieHouston wants to merge 1 commit intobitcoindevkit:masterfrom
EddieHouston:rpc-auth

Conversation

@EddieHouston
Copy link

@EddieHouston EddieHouston commented Jan 16, 2026

Description

This PR adds support for dynamic authorization in Electrum RPC requests.
This enables authentication with authorization-protected Electrum servers and API gateways.

Use cases:

  • Bearer token authentication (JWT, OAuth, API keys)
  • API gateways requiring authorization headers
  • Any dynamic authorization scenario where tokens need to be updated during the client's lifetime

Notes to the reviewers

The implementation uses a callback pattern rather than a static token to support dynamic scenarios like automatic token refresh. The AuthProvider is called for each RPC request, allowing the token to be updated without reconnecting the client.

Both Config and RawClient structs needed custom Debug implementations since function pointers don't implement Debug - this is handled by displaying <provider> when an auth provider is present, which prevents leaking sensitive token values in debug output.

Thread safety is ensured through Arc wrapping and Send + Sync bounds, making this safe for concurrent use across the client.

Tests added:
14 new tests, all passing

Changelog notice

Added: Dynamic authorization support via AuthProvider callback in ConfigBuilder. Enables authentication with authorization-protected Electrum servers and automatic token refresh patterns for Bearer tokens, API keys, and other authorization schemes.

Checklists

All Submissions:

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

Bugfixes:

  • This pull request breaks the existing API
  • I've added tests to reproduce the issue which are now passing
  • I'm linking the issue being fixed by this PR

@notmandatory
Copy link
Member

concept ACK

Thanks for this, adding an "authorization" header is the most common way to authenticate to a http endpoints so this should be generally useful. Appreciate the comprehensive examples and unit tests.

The team is spread a bit thin so it may take some time to get it reviewed and tested.

Copy link
Collaborator

@oleonardolima oleonardolima left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cACK

I did an initial round of review. Overall, it looks good! I'll give it a try testing it.

@luisschwab
Copy link
Member

@EddieHouston needs rebase

@EddieHouston
Copy link
Author

@EddieHouston needs rebase

@oleonardolima Was this still planned to release before the Electrum protocol v1.6 changes?

@oleonardolima
Copy link
Collaborator

@EddieHouston needs rebase

@oleonardolima Was this still planned to release before the Electrum protocol v1.6 changes?

I'm planning to have both under the same release, as the version negotiation is should work fine for new v1.6 and old servers. Appreciate it, if you could do a rebase.

@oleonardolima
Copy link
Collaborator

Also, you could use the opportunity to squash into a single commit, as it touches the same files.

@EddieHouston
Copy link
Author

@EddieHouston needs rebase

@oleonardolima Was this still planned to release before the Electrum protocol v1.6 changes?

I'm planning to have both under the same release, as the version negotiation is should work fine for new v1.6 and old servers. Appreciate it, if you could do a rebase.

Looks like changes for electrum protocol v1.6 break this. The new negotiate_protocol_version are automatically called from constructors... those need to now pass auth as well so the call to server.version can succeed.
Update coming soon

@EddieHouston
Copy link
Author

@EddieHouston needs rebase

@oleonardolima Was this still planned to release before the Electrum protocol v1.6 changes?

I'm planning to have both under the same release, as the version negotiation is should work fine for new v1.6 and old servers. Appreciate it, if you could do a rebase.

Looks like changes for electrum protocol v1.6 break this. The new negotiate_protocol_version are automatically called from constructors... those need to now pass auth as well so the call to server.version can succeed. Update coming soon

I asked Claude Code to fix this issue (squashed the change and force pushed):

Here's the old flow:

RawClient::new_ssl(url, validate_domain, timeout) // 1. creates stream
// 2. negotiate_protocol_version() ← sends server.version WITHOUT auth
// 3. returns client
.with_auth_provider(config.auth_provider()) // 4. sets auth ← TOO LATE

The electrum server requiring auth also requires auth on server.version, so step 2 fails before we ever get to step 4.

The _with_auth constructors fix the ordering:

RawClient::new_ssl_with_auth(url, validate_domain, timeout, auth_provider)
// 1. creates stream
// 2. client.auth_provider = auth_provider ← auth set FIRST
// 3. negotiate_protocol_version() ← sends server.version WITH auth ✓

Tested locally and this works.

@oleonardolima
Copy link
Collaborator

I asked Claude Code to fix this issue (squashed the change and force pushed):

Here's the old flow:

RawClient::new_ssl(url, validate_domain, timeout) // 1. creates stream // 2. negotiate_protocol_version() ← sends server.version WITHOUT auth // 3. returns client .with_auth_provider(config.auth_provider()) // 4. sets auth ← TOO LATE

The electrum server requiring auth also requires auth on server.version, so step 2 fails before we ever get to step 4.

The _with_auth constructors fix the ordering:

RawClient::new_ssl_with_auth(url, validate_domain, timeout, auth_provider) // 1. creates stream // 2. client.auth_provider = auth_provider ← auth set FIRST // 3. negotiate_protocol_version() ← sends server.version WITH auth ✓

Tested locally and this works.

@EddieHouston It looks like Claude didn't fully fixed the issues, CI is failing.

@oleonardolima
Copy link
Collaborator

oleonardolima commented Feb 20, 2026

I asked Claude Code to fix this issue (squashed the change and force pushed):
Here's the old flow:
RawClient::new_ssl(url, validate_domain, timeout) // 1. creates stream // 2. negotiate_protocol_version() ← sends server.version WITHOUT auth // 3. returns client .with_auth_provider(config.auth_provider()) // 4. sets auth ← TOO LATE
The electrum server requiring auth also requires auth on server.version, so step 2 fails before we ever get to step 4.
The _with_auth constructors fix the ordering:
RawClient::new_ssl_with_auth(url, validate_domain, timeout, auth_provider) // 1. creates stream // 2. client.auth_provider = auth_provider ← auth set FIRST // 3. negotiate_protocol_version() ← sends server.version WITH auth ✓
Tested locally and this works.

@EddieHouston It looks like Claude didn't fully fixed the issues, CI is failing.

I asked it to fix it, and it gave this suggestions, see: 17fc5b6

Oh, still failing though 🤦

@EddieHouston
Copy link
Author

EddieHouston commented Feb 23, 2026

I asked Claude Code to fix this issue (squashed the change and force pushed):
Here's the old flow:
RawClient::new_ssl(url, validate_domain, timeout) // 1. creates stream // 2. negotiate_protocol_version() ← sends server.version WITHOUT auth // 3. returns client .with_auth_provider(config.auth_provider()) // 4. sets auth ← TOO LATE
The electrum server requiring auth also requires auth on server.version, so step 2 fails before we ever get to step 4.
The _with_auth constructors fix the ordering:
RawClient::new_ssl_with_auth(url, validate_domain, timeout, auth_provider) // 1. creates stream // 2. client.auth_provider = auth_provider ← auth set FIRST // 3. negotiate_protocol_version() ← sends server.version WITH auth ✓
Tested locally and this works.

@EddieHouston It looks like Claude didn't fully fixed the issues, CI is failing.

The CI runs with --no-default-features --features proxy (no SSL).
The new_proxy_ssl_with_auth method references ElectrumSslStream but is missing the #[cfg] gate that the public new_proxy_ssl has.

@oleonardolima Updated with fix. Ran the cargo check --no-default-features --features proxy locally and see it works now.

@EddieHouston
Copy link
Author

@oleonardolima I'll ran these locally and resolved the issues:
cargo clippy --all-features --all-targets -- -D warnings
cargo fmt --all -- --config format_code_in_doc_comments=true --check
cargo test --verbose --all-features

Newbee question... Is there an easy way to run all the CI test locally?

@EddieHouston
Copy link
Author

EddieHouston commented Feb 23, 2026

@oleonardolima I'll ran these locally and resolved the issues: cargo clippy --all-features --all-targets -- -D warnings cargo fmt --all -- --config format_code_in_doc_comments=true --check cargo test --verbose --all-features

Newbee question... Is there an easy way to run all the CI test locally?
Figured it out...

1. fmt

cargo fmt --all -- --config format_code_in_doc_comments=true --check

2. clippy

cargo clippy --all-features --all-targets -- -D warnings

3. tests

cargo test --verbose --all-features

4. check all feature combinations

cargo check --verbose --features default
cargo check --verbose --no-default-features
cargo check --verbose --no-default-features --features proxy
cargo check --verbose --no-default-features --features openssl
cargo check --verbose --no-default-features --features rustls
cargo check --verbose --no-default-features --features rustls-ring
cargo check --verbose --no-default-features --features proxy,openssl,rustls,rustls-ring

5.

rustup toolchain install 1.75.0
cargo +1.75.0 check --verbose --all-features

Copy link
Collaborator

@oleonardolima oleonardolima left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utACK 09345de

It looks good, though I'm wondering what's the rationale on adding new methods, instead of update the existing ones APIs. Did you want to reduce the number of breaking changes ?

I'll come up with a patch with some changes I have in mind, to see if the API would be better.

Comment on lines +589 to +597
#[test]
fn test_authorization_field_omitted_when_none() {
let req = Request::new_id(1, "test.method", vec![]);

let json = serde_json::to_string(&req).unwrap();

// The JSON should not contain the word "authorization" at all
assert!(!json.contains("authorization"));
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can't you could just move this assertion to the first test ?

/// The protocol version negotiated with the server via `server.version`.
protocol_version: Mutex<Option<String>>,

auth_provider: Option<AuthProvider>,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could use documentation

/// This sends `server.version` as the first message and stores the negotiated
/// protocol version. Called automatically by constructors.
fn negotiate_protocol_version(&self) -> Result<(), Error> {
pub(crate) fn negotiate_protocol_version(&self) -> Result<(), Error> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: what's the rationale on making this pub(crate) ? I didn't see any new usage of it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api API breaking change new feature New feature or request

Projects

Status: Needs Review

Development

Successfully merging this pull request may close these issues.

4 participants