Skip to main content

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:

CapabilityTraitDefault (default-context)
Read filesFileReadTokioFileRead
Send HTTP requestsHttpSendReqwestHttpSend
Read environmentEnvOsEnv
Execute commandsCommandExecuteTokioCommandExecute

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:

  1. A cached credential is reused only if is_valid() and it satisfies the operation's deadline.
  2. Otherwise the provider is asked for a fresh credential, which only needs to satisfy the exact deadline.
  3. 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 Foo has a FooDyn counterpart with a blanket implementation, so Signer stores Arc<dyn FooDyn> internally while you implement the ergonomic non-dyn trait.
  • Trait futures are MaybeSend: Send on native targets, relaxed on wasm32-unknown-unknown, which is why the crate compiles for the browser without an async_trait dependency.

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 buildingUse
An application talking to one or more cloudsfacade + default_signer
An application with a custom runtime or HTTP stackfacade + custom assembly
A library exposing one providerthat service crate directly
A WASM targeteither, without default-context — see Custom runtimes & WASM