Rust’s turnkey OAuth 2.0 broker—spin up multi-tenant flows, CAS-smart token stores, and transport-aware observability in one crate built for production.
- Why oauth2-broker?
- Overview
- Quickstart
- Module Layout
- Broker Capabilities
- Custom HTTP Transports
- Feature Flags
- Extension Traits
- Observability
- Examples & Further Reading
- Development Guardrails
- Support Me
- Appreciation
- Additional Acknowledgements
- Industry-grade OAuth 2.0 broker for Rust — The ecosystem lacks a turnkey, multi-tenant token broker, forcing teams to rebuild authorization code, refresh, and service-to-service flows from scratch. This crate fills that gap with the explicit goal of delivering an industry-level control plane for OAuth clients.
- Flow orchestration baked in —
Broker::start_authorization,Broker::exchange_code,Broker::refresh_access_token, andBroker::client_credentialsalready coordinate state, PKCE, caching, and persistence so product code can focus on user experience instead of grant semantics. - Deterministic storage + concurrency — The
BrokerStoretrait,MemoryStore,FileStore, singleflight guard helpers, andCachedTokenRequestwindows keep token records consistent while letting downstream crates plug in Redis, SQL, or bespoke backends without touching flow logic. - Pluggable HTTP + error mapping —
TokenHttpClient,ReqwestHttpClient,Broker::with_http_client, andResponseMetadataSlotisolate transport wiring whileTransportErrorMapperkeeps error classification observable and stack-agnostic. - Operational visibility by default —
FlowSpan,FlowOutcome,RefreshMetrics, and provider-aware descriptors emit structured traces, counters, and tenant/provider labels so SREs can enforce budgets and SLAs without bolting on custom instrumentation.
oauth2-broker exposes a Broker<C, M> facade that coordinates OAuth 2.0 flows for a single
ProviderDescriptor. Each broker instance owns the token store (BrokerStore), provider strategy,
HTTP client, and transport error mapper, so callers inject tenant/principal/scope context while the
crate reuses shared connection pools and descriptor metadata. Under the hood the crate drives the
upstream oauth2 client through the BasicFacade, layering caching, concurrency control, and
observability on top.
The current codebase ships production-ready implementations for the flows already wired inside
src/flows/:
Broker::start_authorizationandBroker::exchange_codewrap Authorization Code + PKCE, generating state, PKCE pairs, and storingTokenRecordvalues after exchanging the returnedcode.Broker::refresh_access_tokenrotates refresh secrets viaBrokerStore::compare_and_swap_refresh, records metrics throughRefreshMetrics, and removes revoked tokens when providers returninvalid_grant.Broker::client_credentialsreuses cached service-to-service tokens, enforces jittered expiry windows viaCachedTokenRequest, and uses per-StoreKeysingleflight guards so concurrent callers ride the same refresh.
Caching and persistence are abstracted behind the BrokerStore trait, with in-tree backends for an
in-memory MemoryStore and a JSON-backed FileStore. Stores expose compare-and-swap refresh
semantics, revocation helpers, and a stable StoreKey fingerprint so downstream crates can add
Redis or SQL implementations without touching the flow code.
HTTP behavior is centralized in TokenHttpClient and TransportErrorMapper. The crate ships
ReqwestHttpClient plus ReqwestTransportErrorMapper, and any caller can replace them via
Broker::with_http_client to reuse custom TLS, retry, or proxy stacks. Each token request carries a
ResponseMetadataSlot, giving error mappers access to HTTP status codes and Retry-After hints
when translating transport failures.
Observability hooks live under obs/: every flow emits FlowSpan traces and success/attempt/failure
counters, while refresh operations also increment RefreshMetrics. Provider quirks and client-auth
rules are modeled by ProviderDescriptor, GrantType, and ProviderStrategy, so higher-level
systems configure descriptor data and then let the broker enforce those rules consistently across
every flow.
use color_eyre::Result;
use oauth2_broker::{
auth::{PrincipalId, ProviderId, ScopeSet, TenantId},
flows::{Broker, CachedTokenRequest},
provider::{DefaultProviderStrategy, GrantType, ProviderDescriptor, ProviderStrategy},
store::{BrokerStore, MemoryStore},
};
use std::sync::Arc;
use url::Url;
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let store: Arc<dyn BrokerStore> = Arc::new(MemoryStore::default());
let strategy: Arc<dyn ProviderStrategy> = Arc::new(DefaultProviderStrategy);
let descriptor = ProviderDescriptor::builder(ProviderId::new("demo-provider")?)
.authorization_endpoint(Url::parse("https://provider.example.com/authorize")?)
.token_endpoint(Url::parse("https://provider.example.com/token")?)
.support_grants([
GrantType::AuthorizationCode,
GrantType::RefreshToken,
GrantType::ClientCredentials,
])
.build()?;
let broker = Broker::new(store, descriptor, strategy, "demo-client")
.with_client_secret("demo-secret");
let scope = ScopeSet::new(["email.read", "profile.read"])?;
let request = CachedTokenRequest::new(
TenantId::new("tenant-acme")?,
PrincipalId::new("svc-router")?,
scope,
);
let record = broker.client_credentials(request).await?;
println!("access token: {}", record.access_token.expose());
Ok(())
}The snippet relies on the broker's default reqwest-backed transport, the in-memory store, and the
zero-cost DefaultProviderStrategy to reuse cached service-to-service tokens with the
client_credentials grant. For a mock-backed walkthrough that spins up an in-process
httpmock server, see examples/client_credentials.rs;
an authorization-code state/PKCE walk-through lives in
examples/start_authorization.rs.
A provider-specific Authorization Code + PKCE setup for X (Twitter) is available in
examples/x_authorization.rs. It prints the X authorize URL,
prompts for the returned state and code via stdin, exchanges them, and can post a
tweet when you run cargo make example-x-authorization with real client credentials.
src/flows/common.rscentralizes scope formatting, token-response parsing, HTTP error mapping, and singleflight guard lookups. Flow-specific directories keep their heavy logic contained:auth_code_pkce/session.rsowns PKCE/session structs,client_credentials/request.rsholds the jittered cache request type, andrefresh/{request,metrics}.rssplit refresh inputs from the counter set shared withBroker.src/provider/descriptor/now mirrors the descriptor structure itself—grant.rsdefinesGrantType/SupportedGrants,quirks.rscapturesProviderQuirks, andbuilder.rshandles the builder plus validation. Customized HTTP behavior lives withBroker::with_http_client, so tests and downstream crates can inject anyTokenHttpClientimplementation without env-variable shims.src/types/token/separates concerns acrosssecret.rs,family.rs, andrecord.rs, keeping the redacted secret wrapper isolated from the lifecycle-heavy record/builder logic.src/obs/metrics.rsandsrc/obs/tracing.rskeep feature-flagged observability hooks small soobs/mod.rsremains a thin façade.
- Authorization Code + PKCE —
Broker::start_authorizationgenerates state + PKCE material, withBroker::exchange_codehandling HTTPS token exchanges, descriptor-driven PKCE enforcement, and store persistence. - Refresh Token —
Broker::refresh_access_tokenenforces singleflight guards per tenant/principal/scope tuple, rotates refresh tokens through the store’s CAS helpers, and surfaces telemetry viaRefreshMetrics. - Client Credentials —
Broker::client_credentialsreuses cached app-only tokens, joins scopes per provider delimiter, and re-enters the provider only when forced or nearing expiry.
- Public
BrokerStoretrait definesfetch,save,revoke, and refresh CAS semantics. MemoryStore(thread-safe) is the default backend for tests/examples; downstream integrators can implementBrokerStorefor Redis, SQL, etc. without touching flows.
- Every broker owns a dedicated
TokenHttpClienthandle (ReqwestHttpClientby default), so downstream code never wires transports or toggles HTTP-specific feature flags unless they opt in. Broker::with_http_clientaccepts any type that implementsTokenHttpClientplus a correspondingTransportErrorMapper, making it easy to reuse custom TLS, proxy, timeout, or entirely different HTTP stacks whenever the default transport is not sufficient. The same generic pair drives the internalBasicFacade, so every flow consistently works with custom transports.- Token requests are constructed internally from descriptors, grant types, and strategies, keeping the public API focused on OAuth concepts instead of HTTP primitives.
- The default
reqwestfeature provisions the transport automatically so Quickstart snippets stay zero-config, but you can disable it when wiring a customTokenHttpClient.
RequestSignerExt— describe how to attach broker-issued tokens to downstream HTTP clients.TokenLeaseExt— model short-lived access to cached records with readiness metadata.RateLimitPolicy— consult tenant/provider budgets and returnAllow,Delay, or retry hints before flows hit upstream token endpoints.
- Feature flag
tracingemitsoauth2_broker.flowspans forauthorization_code,refresh, andclient_credentialsstages without leaking secrets. - Feature flag
metricsincrementsoauth2_broker_flow_totalcounters (labels:flow,outcome) so exporters such as Prometheus can track attempts/success/failure rates. - Flows call into the observation helpers directly so downstream crates only need to opt into the features and provide their preferred subscriber/recorder configuration.
reqwest(default) — Enables the bundled reqwest transport,Broker::new, integration test helpers, and reqwest-based examples. Disable it (--no-default-featuresordefault-features = false) when you supply your ownTokenHttpClientand mapper viaBroker::with_http_client.test— Re-exports the_preludethelpers outside ofcfg(test)so downstream crates can reuse the integration harness.
Broker<C, M> and the internal BasicFacade<C, M> are generic over both the transport and the
mapper. Calling Broker::new (when the reqwest feature is enabled) instantiates those generics as
Broker<ReqwestHttpClient, ReqwestTransportErrorMapper>, which keeps the Quickstart and HTTP-backed
examples zero-config. TokenHttpClient, ResponseMetadata, ResponseMetadataSlot, and
TransportErrorMapper are re-exported from the crate root so downstream crates can wire their own
stack without depending on private modules.
When you need to wrap an alternate pool, TLS stack, or test double, call
Broker::with_http_client(store, descriptor, strategy, client_id, my_client, my_mapper) and follow
these steps:
- Implement
TokenHttpClientfor your transport. TheHandletype you expose must implementoauth2::AsyncHttpClientand staySend + 'static. The associatedTransportErrorcan be anySend + Sync + 'staticvalue, so your stack never has to referencereqwest::Error. - Emit
ResponseMetadataby cloning the providedResponseMetadataSlot, callingslot.take()before dispatching the request, and persisting status plusRetry-Afterviaslot.store(...)as soon as headers arrive. - Implement
TransportErrorMapper<TransportError>so the broker can translateHttpClientError<TransportError>plus metadata into itsErrorclassification. - Wrap both handles in
Arc(the broker clones them internally) and pass them toBroker::with_http_clientalongside your descriptor, provider strategy, and OAuth client ID.
examples/custom_transport.rs contains a complete walkthrough that registers a mock transport with
a bespoke error type while keeping metadata and mapper wiring intact.
TokenHttpClient hands oauth2 an AsyncHttpClient handle that owns a clone of a
ResponseMetadataSlot, ensuring every transport stores the final HTTP status and Retry-After
hints in a [ResponseMetadata] value. Implementations must:
- Call
slot.take()before dispatching the request so stale metadata never leaks between retries. - Populate
ResponseMetadataviaslot.store(...)as soon as the status/headers are available. - Return an
AsyncHttpClienthandle whose future isSend + 'staticso broker flows can box it. - Propagate the associated
TransportErrortype throughAsyncHttpClient::Error.
The ResponseMetadataSlot and ResponseMetadata types are re-exported from the crate root, which
makes it easy to satisfy the contract without digging through internal modules.
Whenever a transport emits HttpClientError<E>, the mapper receives the provider strategy, active
grant, and the freshest metadata. The trait signature is intentionally public so you can depend on
it directly:
pub trait TransportErrorMapper<E>: Send + Sync + 'static {
fn map_transport_error(
&self,
strategy: &dyn ProviderStrategy,
grant: GrantType,
metadata: Option<&ResponseMetadata>,
error: HttpClientError<E>,
) -> oauth2_broker::error::Error;
}Use this callback to translate transport-specific errors into TransientError, TransportError,
or any other variant that callers rely on for retry/backoff logic. In practice, mappers should:
- Inspect
ResponseMetadatafor HTTP status andRetry-Afterhints before picking a retry class. - Treat
HttpClientError::Reqwest(inner)as "transport error" even ifinneris your customTransportError. The upstreamoauth2crate kept the variant name for compatibility while the payload type is now generic. - Fall back to
HttpClientError::Other,HttpClientError::Http, andHttpClientError::Ioto retain error context that does not come from the transport.
ReqwestTransportErrorMapper demonstrates how reqwest errors become broker Error values, and the
custom transport example mirrors the exact pattern for a mock error type.
Both the client and mapper typically live behind Arc handles so every broker instance can share
them:
let http_client = Arc::new(MockHttpClient::default());
let mapper = Arc::new(MockTransportErrorMapper::default());
let broker = Broker::with_http_client(store, descriptor, strategy, "demo-client", http_client, mapper)
.with_client_secret("demo-secret");The examples/custom_transport.rs walkthrough demonstrates a mock
transport with a non-reqwest error type, ensures metadata recording still works, and wires a mapper
that forwards those errors to the broker. Use it as a template whenever you need to plug in a
custom HTTP stack, simulator, or integration-test fake.
| Feature | Default | Description |
|---|---|---|
tracing |
❌ | Emits tracing spans named oauth2_broker.flow so downstream apps can correlate grant attempts. |
metrics |
❌ | Increments the oauth2_broker_flow_total counter via the metrics crate with flow/outcome labels. |
The MVP ships contracts only for higher-level integrations so downstream crates can experiment without waiting on broker-owned implementations:
ext::RequestSignerExt<Request, Error>— describes how to attach broker-issued tokens to any request builder (the docs show areqwest::RequestBuilderexample).ext::TokenLeaseExt<Lease, Error>— models short-lived access to aTokenRecordvia lease/guard types along with supporting metadata (TokenLeaseContext,TokenLeaseState).ext::RateLimitPolicy<Error>— lets flows consult tenant/provider rate budgets before hitting providers usingRateLimitContext,RateLimitDecision, andRetryDirectivehelpers.
All three traits live under src/ext/, include doc-tested examples, and intentionally ship no
default implementations in this MVP so consumers can plug their own HTTP stack, token cache, and
rate-limit store without extra dependencies.
Tracing + metrics ship disabled by default so downstream crates only pay for what they enable.
Turn them on explicitly in Cargo.toml:
[dependencies]
oauth2-broker = { version = "0.0.1", features = ["tracing", "metrics"] }-
tracingcreates spans namedoauth2_broker.flowwithflow(authorization_code,refresh, orclient_credentials) andstage(start_authorization,exchange_code, etc.). Only enum labels are recorded so client IDs, secrets, and tokens never leave the crate. You can also open spans in your own adapters using the helpers:#[cfg(feature = "tracing")] { use oauth2_broker::obs::{FlowKind, FlowSpan}; let _guard = FlowSpan::new(FlowKind::AuthorizationCode, "my_adapter").entered(); }
-
metricsincrements a counter namedoauth2_broker_flow_totalvia themetricscrate every time a flow attempts, succeeds, or fails. Labels mirror the tracing fields so exporters like Prometheus or OpenTelemetry can break down rates per grant/outcome:#[cfg(feature = "metrics")] { use oauth2_broker::obs::{record_flow_outcome, FlowKind, FlowOutcome}; record_flow_outcome(FlowKind::ClientCredentials, FlowOutcome::Attempt); }
Set up your preferred tracing subscriber and metrics recorder (for example,
metrics-exporter-prometheus) to collect the emitted data.
examples/client_credentials.rs— spins up anhttpmockserver, builds a broker with the default reqwest client, and mirrors the Quickstart flow without touching external networks.examples/custom_transport.rs— shows how to register a customTokenHttpClientplus mapper so transports that do not use reqwest can participate in flows.examples/start_authorization.rs— shows how to generate anAuthorizationSession, persist/lookupstate, and surface PKCE material around a redirect.docs/DESIGN.md— design outline plus the Release Overview section for the MVP crate map, extension traits, observability model, and explicit out-of-scope decisions.CHANGELOG.md— dated release notes (0.0.1 captures the MVP surface).CONTRIBUTING.md— guardrails, quality gates, and reporting instructions.
The tests cover the reqwest-backed flows against an httpmock server plus the
authorization, refresh, and client-credentials flows end to end.
If you find this project helpful and would like to support its development, you can buy me a coffee!
Your support is greatly appreciated and motivates me to keep improving this project.
- Fiat
- Crypto
- Bitcoin
bc1pedlrf67ss52md29qqkzr2avma6ghyrt4jx9ecp9457qsl75x247sqcp43c
- Ethereum
0x3e25247CfF03F99a7D83b28F207112234feE73a6
- Polkadot
156HGo9setPcU2qhFMVWLkcmtCEGySLwNqa3DaEiYSWtte4Y
- Bitcoin
Thank you for your support!
We would like to extend our heartfelt gratitude to the following projects and contributors:
Grateful for the Rust community and the maintainers of reqwest, oauth2, metrics, and tracing, whose work makes this broker possible.
- TODO
Licensed under GPL-3.0.