Architecture
One page on how Reqsign is put together. Everything you compose is one of five pieces:
Signer
├── Context where I/O comes from (files, HTTP, env, commands)
├── ProvideCredential where credentials come from
└── SignRequest the provider's wire protocol
Granter same shape, for downscoping credentials
├── Context
├── ProvideCredential (source credentials)
└── GrantCredential the provider's granting operation
Signers and credential types stay service-specific on purpose — the differences between SigV4, Shared Key, and OAuth are real, and hiding them breaks correctness. What every provider shares is this composition pattern.
Signer
Signer composes the three pieces and adds a credential cache:
use reqsign::Signer;
let signer = Signer::new(ctx, credential_provider, request_signer);
signer.sign(&mut req, None).await?;
Each sign call: checks the cached credential (still valid, and usable
until the operation's deadline?); asks the provider for a fresh one if not;
then hands the request head and credential to the request signer, which
mutates req in place.
The exact behavior of sign — wire-ready URI ownership, atomicity on error,
expires_in semantics — is contractual; see
Signing contracts.
Builders swap any component while keeping the others:
let signer = signer
.with_context(custom_ctx)
.with_credential_provider(static_provider) // clears the credential cache
.with_request_signer(other_signer);
Context
Context answers "where does I/O come from?" Credential providers need to
read files, call metadata endpoints, inspect environment variables, and
sometimes run CLIs — but Reqsign never hard-codes how. Every capability is a
trait slot:
| Capability | Trait | Default (default-context) |
|---|---|---|
| Read files | FileRead | TokioFileRead |
| Send HTTP requests | HttpSend | ReqwestHttpSend |
| Read environment | Env | OsEnv |
| Execute commands | CommandExecute | TokioCommandExecute |
Context::new() wires no-op stubs, not silent fallbacks: a credential
source that needs HTTP against a stub context returns an error instead of
quietly doing nothing, so you always know which capabilities you granted.
The facade's default_context() returns the full assembly in the table.
use reqsign::aws::{DefaultCredentialProvider, RequestSigner};
use reqsign::{Context, OsEnv, Signer};
use reqsign_command_execute_tokio::TokioCommandExecute;
use reqsign_file_read_tokio::TokioFileRead;
use reqsign_http_send_reqwest::ReqwestHttpSend;
fn main() {
let ctx = Context::new()
.with_file_read(TokioFileRead)
.with_http_send(ReqwestHttpSend::default())
.with_env(OsEnv)
.with_command_execute(TokioCommandExecute);
let _signer = Signer::new(
ctx,
DefaultCredentialProvider::new(),
RequestSigner::new("s3", "us-east-1"),
);
}
This explicit assembly also needs reqsign-file-read-tokio,
reqsign-command-execute-tokio, and reqsign-http-send-reqwest as direct
dependencies; default_context() supplies the same components through the facade.
Why traits instead of feature flags: tests inject static env maps and canned HTTP responses without process-global mutation; sandboxes withhold capabilities by simply not providing them; and WASM targets provide browser-backed implementations. See Custom runtimes & WASM.
ProvideCredential
One trait behind every credential source — an env reader, a config-file parser, an IMDS client, an OIDC exchanger:
pub trait ProvideCredential {
type Credential;
async fn provide_credential(&self, ctx: &Context)
-> Result<Option<Self::Credential>>;
}
The Option is the chain protocol:
Ok(Some(credential))— this source resolved a credential; use it.Ok(None)— this source has nothing to offer (env var unset, file missing); try the next source.Err(e)— this source failed (malformed file, network error). A directly installed provider propagates this error to the signer.
ProvideCredentialChain logs errors and continues to the next provider, just
as it does for None. It returns Ok(None) when no provider succeeds. Do not
use this chain when a source failure must prevent fallback to another identity.
Chains compose providers in order and take the first Some; because "not
configured" is None rather than an error, a chain of ten sources stays
quiet until one applies.
type Credential is deliberately not a universal struct: AWS needs an
access key pair plus optional session token; Azure models SharedKey,
SasToken, and BearerToken as an enum. Each service crate defines the
credential its signer consumes, and the type system keeps mismatches
impossible. Working with chains and writing your own provider:
Loading credentials.
SignRequest
SignRequest implements a provider's wire protocol: given a request head
and a credential, produce that provider's exact authentication format.
pub trait SignRequest {
type Credential;
fn required_valid_until(
&self,
credential: &Self::Credential,
expires_in: Option<Duration>,
) -> Timestamp;
async fn sign_request(
&self,
ctx: &Context,
req: &mut http::request::Parts,
credential: Option<&Self::Credential>,
expires_in: Option<Duration>,
) -> Result<()>;
}
reqsign_aws_v4::RequestSigner produces SigV4's canonical request and
Authorization header — or X-Amz-* query parameters when expires_in is
set. reqsign_azure_storage::RequestSigner produces Shared Key headers or
appends a SAS token. The trait is shared; the wire behavior deliberately is
not.
required_valid_until is how signing stays ahead of expiration: before
signing, the signer asks how long the credential must remain usable — "now"
for a header signature, the full window for a presigned URL — and refreshes
a cached credential that cannot cover it.
Credential caching and validity
Credential loading can be expensive (an IMDS round-trip, an OIDC exchange),
so Signer and Granter cache what they load. Two methods on
SigningCredential govern reuse:
pub trait SigningCredential {
/// May a cached credential be reused? Implementations may include a
/// proactive refresh window.
fn is_valid(&self) -> bool;
/// Is the credential usable at this exact timestamp? No buffers.
fn is_valid_at(&self, ts: Timestamp) -> bool;
}
The two checks have different jobs: is_valid answers the caching
question (refresh early rather than mid-flight); is_valid_at answers the
operation question against a deadline (a credential fine for header
signing can be insufficient for a one-hour presign window). Both must reject
credentials lacking the fields required for authentication.
The reuse rule on every sign or grant:
- A cached credential is reused only if
is_valid()and it satisfies the operation's deadline. - Otherwise the provider is asked for a fresh credential, which only needs to satisfy the exact deadline.
- Errors returned by the configured provider are propagated without retry or reuse of the previous cached credential. A configured provider chain can handle errors internally by trying its next source, as described above.
Replacing a signer's credential provider — or a granter's context or source provider — clears the cache: a credential loaded under one configuration is never reused under another.
Granter and GrantCredential
Signing proves a request is yours; granting gets you a narrower credential first. Providers each have an operation for this (S3 Access Grants, S3 Express CreateSession, Azure user delegation SAS, GCP Credential Access Boundary); Reqsign unifies their shape without hiding their semantics:
pub trait GrantCredential {
type Credential: SigningCredential;
fn required_valid_until(
&self,
credential: &Self::Credential,
expires_in: Option<Duration>,
) -> Timestamp;
async fn grant_credential(
&self,
ctx: &Context,
credential: &Self::Credential,
expires_in: Option<Duration>,
) -> Result<Self::Credential>;
}
use reqsign::Granter;
let granter = Granter::new(ctx, source_provider, granting_operation);
let scoped = granter.grant(Some(Duration::from_secs(900))).await?;
Granter caches the source credential with the same rules as Signer,
and a granted result owns independent material — it never aliases the cached
source, so using it after the source rotates is safe. The walkthrough lives
in Granting scoped access.
Compiles everywhere: dyn pairs and MaybeSend
Two mechanics keep this composition model portable:
- Every async trait
Foohas aFooDyncounterpart with a blanket implementation, soSignerstoresArc<dyn FooDyn>internally while you implement the ergonomic non-dyn trait. - Trait futures are
MaybeSend:Sendon native targets, relaxed onwasm32-unknown-unknown, which is why the crate compiles for the browser without anasync_traitdependency.
Where to plug in
The same components are reachable at three levels — pick by how much you want to control:
The facade (reqsign crate) — one dependency, feature flags per
provider, default_signer entries that wire everything. Best for
applications; still customizable through with_* builders.
Custom assembly — the same components, wired explicitly. What
default_signer does for you, written out:
use reqsign::aws::{DefaultCredentialProvider, RequestSigner};
use reqsign::{Context, OsEnv, Signer};
use reqsign_command_execute_tokio::TokioCommandExecute;
use reqsign_file_read_tokio::TokioFileRead;
use reqsign_http_send_reqwest::ReqwestHttpSend;
fn main() {
let ctx = Context::new()
.with_file_read(TokioFileRead)
.with_http_send(ReqwestHttpSend::default())
.with_env(OsEnv)
.with_command_execute(TokioCommandExecute);
let _signer = Signer::new(
ctx,
DefaultCredentialProvider::new(),
RequestSigner::new("s3", "us-east-1"),
);
}
Service crates directly — libraries supporting exactly one provider can
skip the facade and depend on reqsign-core plus that service crate,
letting the application choose context implementations:
[dependencies]
reqsign-core = "3"
reqsign-aws-v4 = "3"
| You are building | Use |
|---|---|
| An application talking to one or more clouds | facade + default_signer |
| An application with a custom runtime or HTTP stack | facade + custom assembly |
| A library exposing one provider | that service crate directly |
| A WASM target | either, without default-context — see Custom runtimes & WASM |