faucet-auth
Shared, single-flight authentication providers for the faucet-stream ecosystem. Each provider implements faucet_core::AuthProvider — a live entity that owns a token cache and refresh lifecycle.
The point of this crate is token sharing: one provider instance, wrapped in an Arc, is handed to every connector that authenticates against the same identity provider. So N connectors (or N matrix rows) hitting one IdP share a single token with single-flight refresh, instead of each racing to mint or rotate its own. That is the difference between one token request per run and one per connector per refresh window — and, for rotating refresh tokens, the difference between working and invalidating each other.
Feature highlights
- Four provider types — a fixed static credential, OAuth2
client_credentials, OAuth2refresh_tokenwith rotation capture, and a generic JSONPath-extracting token endpoint. - Single-flight refresh — concurrent callers during a refresh await the one in-flight fetch; they don't stampede the token endpoint.
- Force-refresh on rejection — a connector that gets a
401callsinvalidate(stale)and receives a freshly-fetched token. Concurrent invalidations of the same token collapse into one refresh via compare-and-swap. - Refresh-token rotation capture —
oauth2_refreshcaptures a rotatedrefresh_tokenfrom each response in place, so a single active access token plus a rotating refresh token can be shared safely across many connectors. - Secret-safe
Debug— every provider'sDebugimpl renders secrets (client_secret, refresh token, request body, cached access token) as***; only non-secret identifiers stay visible. - Bounded fetch timeout — providers hold a single-flight mutex across the network call, so the internal HTTP client has a 30 s request timeout: a hung IdP fails and releases the lock instead of wedging every connector that shares the provider.
- Validated config at load time —
expiry_ratiois checked to be a finite number in(0, 1]; unknown providertypes and missing required fields surface asFaucetError::Configbefore any run starts.
Installation
# As a library:
# In the CLI, the shared `auth:` catalog is always available — no feature flag needed.
# Library callers who want the umbrella crate to build providers enable the `auth` feature:
The faucet-cli binary always links faucet-auth to power the top-level auth: catalog, so config-driven users get every provider type out of the box.
What it provides
type (config) |
Rust type | What it does |
|---|---|---|
static |
StaticProvider |
Returns a fixed, pre-minted credential forever — bearer token, custom header, or HTTP Basic. No network calls. |
oauth2 |
OAuth2ClientCredentialsProvider |
OAuth2 client_credentials grant. Fetches a token from the token endpoint, caches it, refreshes single-flight. |
oauth2_refresh |
OAuth2RefreshProvider |
OAuth2 refresh_token grant with refresh-token rotation capture (a single active access token + a rotating refresh token, shared safely). |
token_endpoint |
TokenEndpointProvider |
Fetches a token from any HTTP endpoint and extracts it from the JSON response via JSONPath. The escape hatch for non-standard token APIs. |
oauth1 |
OAuth1Provider |
OAuth1 one-legged request signing (HMAC-SHA256) — signs each request's method + URL + query per RFC 5849 (no token to fetch). For NetSuite Token-Based Auth and similar. Requires the oauth1 crate feature. |
build_provider(&Value) is the entry point: it reads a { type, config } spec and returns a SharedAuthProvider (Arc<dyn AuthProvider>).
Provider configuration reference
Every provider is built from a { type, config } object — the project-wide adjacently-tagged auth shape. The fields below are the keys inside each provider's config.
flow (composable multi-step auth)
A small declarative auth program (#511): an optional login / pre-flight request chain whose responses are captured by JSONPath, credential placement across header / query / cookie / body, a pluggable HMAC request signer, and a dynamic per-session base-URL — on top of the single-flight machinery. It's the single biggest source-side unlock for session-cookie / multi-step ERP APIs (Bullhorn, Acumatica, SAP, Sage/Intacct, SkySlope, …).
| Field | Type | Default | Description |
|---|---|---|---|
steps |
array | [] |
Login/pre-flight chain. Each: request: { method, url, headers, query, form | json, sign } + capture: { name: <source> }. Later steps and apply see captured values via ${name}. |
apply |
array | [] |
Per-request placements: { into: header|query|cookie|body, name, value: "${captured}" }, or a signer { sign: { alg: hmac_sha256, key, template, encoding: hex|base64, into: { header, format: "${sig}" } } }. |
base_url_from |
string / null | null |
Template (over captured values) yielding a per-session base-URL that overrides the connector's configured one. |
ttl_secs |
int / null | null |
Re-run the login chain after this many seconds. |
reauth_on |
array | [] |
HTTP statuses that trigger a re-login + one retry (honored by the REST source). |
Signing a login step (#541)
A login/pre-flight request can carry its own sign: block — the same
SignSpec shape used in apply. It's computed with a fresh ${ts}/${nonce}
clock per step and attached as a header, so APIs whose login call is itself
HMAC-signed (e.g. SkySlope) are expressible from config. On the first step
nothing has been captured yet, so its template may reference only
${param.*} / ${env:*} / ${ts} / ${nonce} (later steps also see values
captured by earlier steps).
auth:
skyslope:
type: flow
config:
steps:
- request:
method: POST
url: "${param.base_url}/auth/login"
json:
sign: # sign the login step itself
alg: hmac_sha256
key: "${param.secret_key}"
template: "${param.client_id}:${param.client_secret}:${ts}"
encoding: base64
into:
capture:
apply:
-
-
Capture sources (#542)
By default a capture entry is a JSONPath string into the JSON response
body ({ session: "$.session.token" } — unchanged, back-compatible). It can
also be a { from, … } struct to capture from other parts of the response:
from |
Selector | Captures |
|---|---|---|
json (default) |
path: "$.jsonpath" |
JSONPath into the JSON body (same as the bare-string form). |
xml |
path: "a.b.c" |
Dot-path into an XML body, by element local name (namespace prefixes are ignored). |
header |
name: "Location" |
A response header value (case-insensitive). |
set_cookie |
name: "ASP.NET_SessionId" |
A specific Set-Cookie value, selected by cookie name. |
steps:
- request:
capture:
session_cookie: # Acumatica: Set-Cookie + 204 empty body
sess_id: # Sage Intacct: XML session id
loc: # any response header
apply:
-
A shared cookie jar (auto-forwarding login cookies to data requests without an explicit capture) is future work; the
from: set_cookie→into: cookiepath covers the same case today.
auth:
bullhorn:
type: flow
config:
steps:
- request:
capture:
- request:
capture:
apply:
-
base_url_from: "${base_url}"
reauth_on:
Header placements also apply to xml / graphql sources (via credential());
query / cookie / body placement and dynamic base-URL are consumed by the rest
source (which reads the richer request_auth).
static
A fixed credential. Exactly one of the three shapes below must be present.
| Field | Type | Default | Description |
|---|---|---|---|
token |
string | — | Bearer token → Authorization: Bearer <token>. |
header + value |
string + string | — | A custom header credential (e.g. X-Api-Key). Both keys required together. |
username + password |
string + string | — | HTTP Basic credentials. Both keys required together. |
oauth2 (client-credentials)
| Field | Type | Default | Description |
|---|---|---|---|
token_url |
string | — (required) | OAuth2 token endpoint. |
client_id |
string | — (required) | OAuth2 client ID. |
client_secret |
string | — (required) | OAuth2 client secret. |
scopes |
array of string | [] |
Scopes, sent space-joined as the scope form field. |
expiry_ratio |
number | 0.9 |
Refresh after expires_in × expiry_ratio seconds. Must be a finite number in (0, 1]. |
oauth2_refresh
| Field | Type | Default | Description |
|---|---|---|---|
token_url |
string | — (required) | OAuth2 token endpoint. |
client_id |
string | — (required) | OAuth2 client ID. |
client_secret |
string | — (required) | OAuth2 client secret. |
refresh_token |
string | — (required) | Initial (seed) refresh token. A rotated token in the response is captured in place for the next refresh. |
scope |
string | (none) | Optional scope sent on the refresh grant. Some IdPs require it on refresh (e.g. Microsoft Graph needs https://graph.microsoft.com/.default offline_access). Omitted entirely when unset, so the refresh inherits the original grant's scope (RFC 6749 §6). |
expiry_ratio |
number | 0.9 |
Refresh after expires_in × expiry_ratio seconds. Must be a finite number in (0, 1]. |
persist |
object | (none) | Durably persist the rotated refresh_token so a later run authenticates after the token rotates (providers like Microsoft / Rippling rotate on every refresh; without this the second scheduled run 401s). persist.path is a file-backed state-store directory; the rotated token is written after each refresh and re-read on startup in preference to the config seed. Optional persist.key overrides the auto-derived (stable, identity-scoped) storage key. |
persist example:
auth:
graph:
type: oauth2_refresh
config:
token_url: "https://login.microsoftonline.com/${param.tenant}/oauth2/v2.0/token"
client_id: "${secret:graph_client_id}"
client_secret: "${secret:graph_client_secret}"
refresh_token: "${secret:graph_seed_refresh_token}" # seed; used only until the first rotation
scope: "https://graph.microsoft.com/.default offline_access" # Graph requires scope on refresh
persist:
path: "./state/auth" # rotated refresh_token survives across runs
token_endpoint
| Field | Type | Default | Description |
|---|---|---|---|
url |
string | — (required) | HTTP endpoint to fetch the token from. |
method |
string | POST |
HTTP method. |
encoding |
string | json |
Request-body encoding: json (default) or form (application/x-www-form-urlencoded, required by OAuth token endpoints that expect a resource= param). form requires a flat object body of string/number/boolean values. |
body |
object | (none) | Request body (e.g. carrying client_id / client_secret / resource). Omit for a GET. |
token_path |
string | — (required) | JSONPath selecting the token from the response (e.g. $.auth.access_token). String and numeric matches are accepted. |
expiry_path |
string | (none) | JSONPath selecting expires_in (seconds) from the response. When absent, the token is cached without an expiry. |
expiry_ratio |
number | 0.9 |
Refresh after expires_in × expiry_ratio seconds. Must be a finite number in (0, 1]. |
apply_as |
object | (Bearer) | Where the fetched token is placed on each request. Default is Authorization: Bearer <token>. Set apply_as: { header, template } to place it in an arbitrary header (e.g. a session cookie): template is the header value with {token} substituted (defaults to the bare token). |
apply_as example — SAP Business One session cookie:
auth:
sap:
type: token_endpoint
config:
url: "https://host:50000/b1s/v1/Login"
body:
token_path: "$.SessionId"
apply_as:
header: "Cookie"
template: "B1SESSION={token}; CompanyDB=${param.company_db}"
oauth1 (HMAC-SHA256 request signing)
Unlike the token providers, OAuth1 has no token to fetch — it signs every
request individually (the signature covers the HTTP method, URL, and query
parameters). Requires the oauth1 crate feature (cargo add faucet-auth --features oauth1, or cargo install faucet-cli --features oauth1).
| Field | Type | Default | Description |
|---|---|---|---|
consumer_key |
string | — (required) | OAuth1 consumer (client) key. |
consumer_secret |
string | — (required) | OAuth1 consumer secret. |
token |
string | — (required) | OAuth1 access token. |
token_secret |
string | — (required) | OAuth1 access-token secret. |
realm |
string | (none) | Optional realm (e.g. the NetSuite account id). |
signature_method |
string | HMAC-SHA256 |
Only HMAC-SHA256 is supported. |
auth:
netsuite:
type: oauth1
config:
consumer_key: "${secret:ns_consumer_key}"
consumer_secret: "${secret:ns_consumer_secret}"
token: "${secret:ns_token}"
token_secret: "${secret:ns_token_secret}"
realm: "${param.account}" # NetSuite account id
CLI usage — the top-level auth: catalog
Define a provider once in the top-level auth: catalog, then reference it from any connector via auth: { ref: <name> }. The CLI builds each named provider a single time and injects the same Arc into every connector that references it, so all the rows that share a ref share one token.
# faucet run pipeline.yaml
version: 1
auth:
sf:
type: oauth2_refresh
config:
token_url: https://<acct>.snowflakecomputing.com/oauth/token-request
client_id: ${secret:SF_CLIENT_ID}
client_secret: ${secret:SF_CLIENT_SECRET}
refresh_token: ${secret:SF_REFRESH_TOKEN}
pipeline:
sources:
sf_table:
type: snowflake
config:
account: ${vars.account}
auth: # every row using this template shares ONE token
sink:
type: jsonl
config:
path: ./out.jsonl
Eight connectors consume shared providers via auth: { ref }: rest, graphql, xml, grpc, websocket, sink-http, elasticsearch, and snowflake. (Kafka / BigQuery / GCS keep the same { type, config } wire shape but are not bearer/token-based, so they don't take auth: { ref }.)
Provider configs can hold ${env:…} / ${file:…} / ${secret:…} / ${vault:…} / ${aws-sm:…} indirection — the secrets pass walks the auth: catalog, so a single shared provider can hold a secret-manager reference and be reused across every row.
Inline auth vs shared provider
A connector's auth field accepts either an inline { type, config } block or a { ref: <name> } pointer. Use a ref (and the auth: catalog) whenever more than one connector / matrix row authenticates against the same IdP — that's when token sharing and single-flight refresh actually pay off. A one-off connector can keep its auth inline.
Library usage
Build a provider, wrap it in an Arc, and clone it into every source/sink that should share the token via with_auth_provider:
use Arc;
use ;
use SharedAuthProvider;
use json;
#
Asking the provider for a credential is then a one-liner; the cache, refresh, and single-flight coordination are internal:
use ;
# async
How single-flight refresh works
Each fetching provider (oauth2, oauth2_refresh, token_endpoint) holds a Mutex-guarded token cache and performs the token-endpoint call with the lock held:
- Cache hit —
credential()returns the cached token untilexpires_in × expiry_ratiohas elapsed. - Cache miss / expiry — the first caller takes the lock and fetches; concurrent callers block on the same lock and, when it's released, observe the freshly-cached token. Result: one network fetch serves an arbitrary number of concurrent callers.
- Force-refresh on
401— a connector whose request was rejected callsinvalidate(stale). This is a compare-and-swap: it refetches only if the cache still holds thestaletoken. If a concurrent caller already refreshed (the cache now holds a different token), the stale caller gets that new token back without a second fetch. - Rotation capture (
oauth2_refreshonly) — each refresh response may carry a rotatedrefresh_token; the provider stores it in place, so the next refresh uses the latest rotated token. This is exactly the case that breaks when each connector keeps its own copy of a rotating refresh token.
The internal HTTP client has a bounded 30 s request timeout so a hung or unreachable IdP fails the fetch and releases the single-flight lock, rather than wedging every connector that shares the provider.
expiry_ratio (default 0.9) must be a finite number in (0, 1], validated at construction. A value ≤ 0 (or NaN) would expire every token immediately, defeating the cache and single-flight refresh; a value > 1 would treat the token as valid past its real expiry, causing 401s mid-use. Both are rejected at config-load time.
Feature flags
This crate has no optional Cargo features of its own. It is pulled in by:
- the
faucet-clibinary (always — for the top-levelauth:catalog); - the
faucet-streamumbrella crate'sauthfeature, for library callers who wantbuild_provideravailable alongside the connectors.
Troubleshooting / FAQ
| Symptom | Likely cause & fix |
|---|---|
Config: auth provider: unknown type ... |
The type isn't one of static / oauth2 / oauth2_refresh / token_endpoint. Check the spelling. |
Config: ... missing 'type' |
The provider spec has no type key. Each auth: catalog entry needs { type, config }. |
Config: oauth2 auth provider: missing 'client_id' (or token_url / client_secret / refresh_token) |
A required OAuth2 field is absent. All of token_url, client_id, client_secret are required; oauth2_refresh also requires refresh_token. |
Config: static auth provider: config must contain ... |
The static config didn't match token, header+value, or username+password. Provide exactly one of those shapes. |
Config: ... 'expiry_ratio' must be a finite number in (0, 1] |
expiry_ratio is out of range or non-numeric. Use a value like 0.9; remove the field to take the default. |
Auth: OAuth2 token request failed (HTTP 401): ... |
The IdP rejected the client credentials / refresh token. Verify client_id / client_secret, and that the refresh_token hasn't already been rotated/revoked by a stale copy elsewhere — share one oauth2_refresh provider via auth: { ref }. |
Auth: token endpoint request failed (HTTP ...) |
The token_endpoint url returned a non-2xx. Check the URL, method, and body. |
Auth: token_path '...' did not match a string value |
The token_path JSONPath didn't select a string/number in the response. Inspect the real response body and fix the path (e.g. $.auth.access_token). |
| Token endpoint hangs the whole pipeline | A single-flight fetch is blocked. Providers cap each fetch at 30 s, after which the lock is released and the call fails with FaucetError; check IdP reachability and the token_url. |
| Every request re-fetches a token | An expiry_ratio near 0 (or a missing expiry_path on token_endpoint combined with no caching expectation) shortens the cache window. Set a sensible expiry_ratio and an expiry_path that selects the response's TTL. |
See also
- Authentication cookbook — the full
{ type, config }vs{ ref }model, per-connector auth methods, and the sharedauth:catalog. - Secrets cookbook — feeding
${secret:…}/${vault:…}references into provider configs. faucet-core— defines theAuthProvidertrait,Credentialenum, andSharedAuthProvider/AuthSpectypes this crate implements against.
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.