Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Auth Model: Credentials vs Usage

Authentication in Capi rests on one separation: getting a credential and using one are different concerns. An endpoint declares that it needs a credential; a credential store holds the actual secret; and the traversal’s seal places the stored secret into each request that asked for it. Keep those three apart and every auth strategy in the next two chapters is a variation on the same machine.

The two halves

  • Usage is declarative and lives on the endpoint. An endpoint’s auth() says which credential slot this request draws from — nothing about what the secret is.
  • Credentials are runtime state and live on the config, in an AuthStore. You put a secret in once (api.authenticate("…")); it stays there and rides every request that declared the matching tier.

Because they’re separate, the same endpoint definition works before and after you authenticate, works with a key from an env var or a vault, and can target a second credential tier without any change to its body or decoder.

AuthStore, keyed by AuthKey

The store lives in capi_authentication and is re-exported at capi_core::authorization and in the prelude. It is a small, Arc-backed, cloneable map from an AuthKey to a credential:

pub enum AuthKey {
    Default,          // the primary credential
    Named(Arc<str>),  // a second (or third) tier, e.g. "management"
}

You rarely touch AuthStore directly — the scheme’s authenticate(...) does it for you — but the write underneath the Token scheme is:

store.set_token(AuthKey::Default, api_key);   // dressed with the store's default placement
store.set_token("management", other_key);     // `impl Into<AuthKey>`: &str → Named

set_token stores a bare token dressed with the placement the config declared once — written into the store’s default_header_name behind its default_prefix, which AuthStore::new().with_default_prefix("Bearer ") and .with_default_header_name(..) set, and which AuthConfigExt::bearer and with_placement build for you. The token handed in later needs no knowledge of the placement. The undressed write is set_auth(key, header_value), which stores the value as the whole header; both accept impl Into<AuthKey>, so a &str, String, or Arc<str> becomes a Named tier automatically. The stored token is marked sensitive on insertion, so it’s redacted in logs and Debug output. Lookups use entry(&key) / contains(&key) — there’s no get — and remove_auth(&key) / clear() are logout: they drop the entry and any renewal seat with it.

A scheme prefix is stored beside its token, not inside it

A credential written into Authorization usually carries a scheme name in front of it — Bearer <token>. The store keeps those two apart:

store.set_auth_entry(
    AuthKey::Default,
    AuthEntry::prefixed(AUTHORIZATION, "Bearer ", token),
);

The prefix is joined to the token verbatim when the credential is injected, so it owns its own separator: "Bearer " includes the space, "xai-client-secret." includes the dot. Keeping them apart is what lets an endpoint present the same secret somewhere else under a different dressing (below), and it sharpens redaction — the scheme stays readable in Debug output while the token is replaced.

A credential can occupy more than one place

What the store holds under a key is an AuthEntry: an ordered set of placements, each one an AuthPart. set_auth builds the common case — a single header, the store’s default_header_name — and set_auth_with_header picks a different header for that one entry. Services that split a credential across several headers, that take an API key as a query parameter, or that expect it as a WebSocket subprotocol entry build the entry themselves and store it with set_auth_entry:

// An app id beside an app key, both required on every call.
store.set_auth_entry(
    AuthKey::Default,
    AuthEntry::new(HeaderName::from_static("x-app-id"), app_id)
        .and_header(HeaderName::from_static("x-app-key"), app_key),
);

// An API key the service wants in the query string.
store.set_auth_entry(AuthKey::Default, AuthEntry::query_param("key", api_key));

The seal applies every part of the matched entry: headers are inserted with their prefix and token joined, and query parts are percent-encoded and appended to whatever query the request already carries. Every token is sensitive regardless of where it lands — but a query credential ends up inside the URL, which proxies and access logs record verbatim, so prefer a header whenever the service accepts one.

An entry is stored and replaced as a whole, so a re-minted multi-part credential is never observed half-applied: a request either carries all of the old credential or all of the new one.

What else an entry can be

Placements are the common case, not the whole of it. An AuthEntry can also:

  • Be a presence. AuthEntry::presence() has no parts at all: it says the credential exists, and another mechanism presents it — the session cookie a jar writes at the Late seat. A required declaration resolves against it and the seal writes nothing, which lets an endpoint riding such a credential declare its tier honestly instead of Auth::NONE.
  • Expire, and be renewed. AuthEntry::minted(token, &ctx) stamps an expiry from the context clock and the credential’s own lifetime; expiring_at(..) sets one by hand; store.expires_at(&key) reads it back. A store can hold a seat for the key — seat_refresher / unseat_refresher / has_refresher — and the seal’s resolution renews a stale seated credential at the moment of use (Freshness::Ensure, the default; Skip places it as it stands). A failed renewal aborts the request even under optional(): optional describes tolerable absence, and a credential whose upkeep broke is not absent. store.refresh(&ctx, &key) drives the seat by hand; the OAuth2 chapter shows how a flow seats one.
  • Carry key material. AuthEntry::from_key_material(..) and with_key_material(..) hold the custody handle a signer signs with, beside any placed parts — AWS temporary credentials are a placed session token next to a signing key. The seal refuses a placed header that collides with a header the signer declares as its own output.
  • Come from a source. AuthStore::with_source(..) / from_source(..) install an AuthSource, the pluggable backend consulted when the entry map misses: a vault, a keychain, a file. It answers a fetch(ctx, key) with the same bundle the map would have held, so consumers never know the difference.

Auth: the endpoint’s declaration

An endpoint declares its need with an Auth, returned from auth():

Auth::DEFAULT              // draw from AuthKey::Default (the common case; also the default)
Auth::named("management")  // draw from AuthKey::Named("management")
Auth::NONE                 // send no credential at all

An Auth names zero or more credentials, each carrying an optional flag, so two builder methods tune how strict a requirement is:

Auth::DEFAULT.optional()   // inject the credential if present, proceed anonymously if not
Auth::named("x").required() // the default stance: fail if the credential is missing
Auth::DEFAULT.redacted()   // keep the credential's shape, place REDACTED_PLACEHOLDER instead

These three stances map onto how real services treat authentication:

  • required() — the default. Many services expect every call to be authenticated, and for those a request with no credential shouldn’t even leave the machine — sending it just earns a 401 round trip. required() fails fast in that case, catching a missing token before anything goes on the wire.
  • optional() — auth-capable but not auth-mandatory. Some services accept a credential without demanding one: send a token and you get the authenticated view, send nothing and you get the public one. optional() captures exactly that — inject the credential if the store has it, otherwise proceed anonymously. This is what makes icecast’s public-stream client work: its endpoints declare .optional(), so a call with no stored credential simply goes out unauthenticated rather than erroring.
  • NONE — authentication must not be attached. A few endpoints should carry no credential at all, and NONE guarantees it: the auth header is left off even when a credential is present. That guarantee is the point — it stops you from leaking a token to a place it doesn’t belong. The clearest cases are endpoints that mint credentials (a login or token-exchange call has nothing to authenticate with yet), and requests to a pre-signed URL: an authenticated endpoint hands back a download location whose authorization is already baked into the URL’s signature, so the follow-up download is modeled as its own NONE endpoint — attaching your API credential there would be both unnecessary and a needless disclosure.

There is a fourth stance for fixtures and logs. redacted() keeps the shape of the request’s authentication — the headers and query parameters a live placement would write, the signer’s declared outputs — with REDACTED_PLACEHOLDER standing where each value would go; nothing is renewed and no signer runs, so the request holds no credential at all. RequestOverrides::redact_credentials() applies the same stance to one send, which is how the recorder captures a request’s redacted twin — the redaction chapter and Fixtures and Sensitive Data.

Most endpoints never call auth() explicitly — the framework default is Auth::DEFAULT.required() — and you override it only to reach a named tier, relax to optional(), or opt out with NONE.

The endpoint also says where the credential rides

Which header carries a credential is endpoint vocabulary, not credential vocabulary. The clearest case is a realtime API reachable from a browser: a WebSocket constructor takes a URL and a subprotocol list and nothing else, so the token has to ride Sec-WebSocket-Protocol there — while the same service’s HTTP endpoints take the same secret as Authorization: Bearer …, drawn from the same store slot.

Two builders on the declaration say so:

Auth::DEFAULT.via_header(X_API_KEY, "")          // write the token into this header
Auth::DEFAULT.append_header(SEC_WEBSOCKET_PROTOCOL, "xai-client-secret.")  // offer it in this list

via_header writes the header, replacing whatever is there; append_header extends its comma-separated list, so a session’s static subprotocol entries stay first and the credential lands after them. The prefix is joined to the token verbatim, exactly as a stored prefix is — "" for a header that takes the bare token. A directive replaces the stored credential’s own header placement for that one request, and it carries a recipe rather than a secret: the endpoint never sees the token.

An endpoint that needs two credentials at once — an application key beside a user token — chains .and(…), and every builder refines the requirement added most recently:

Auth::DEFAULT.and(Auth::named("app").via_header(X_APP_ID, ""))

How a declaration becomes a credentialed request

The endpoint’s declaration reaches the seal as a marker on the request head, and the seal resolves it against the store. It is one step of the request lifecycle:

graph LR
    ep["endpoint.auth()<br/><small>→ key, optional, placement</small>"]
    lm["the seal<br/><small>looks up the key</small>"]
    hdr["every part applied<br/><small>headers set, query appended</small>"]
    ep --> lm --> hdr

The seal runs after every pipeline member and after the rate-limit gate, with nothing between it and the wire. If the declaration is optional() and the store has no matching entry, placement is skipped and the request proceeds without a credential.

That position is what the whole model rests on, and it buys three things. First, secrecy: for the whole member band the request simply has no credential in it, so a logging or tracing member running in the middle can’t capture a token — there is nothing there to capture yet. Second, freshness: resolution renews a seated credential at the moment of use, so it goes stale neither during a runner’s backoff nor during the gate’s own wait. Third, an intact signature: there is no step between the seal and the wire for anything to occupy, so what a signing scheme signed is exactly what crosses the socket.

The scheme surface

Wiring auth up touches three things, each covered next:

  • An auth scheme is a marker type naming a flow handle — the struct whose inherent authenticate(...) has that scheme’s parameters. The framework ships Token and Basic for a credential handed in and stored; the auth add-ons ship the network ones; #[derive(AuthScheme)] on a carrier struct derives one that mints through an endpoint of your own.
  • #[capi(interface)] on your API struct makes it be the interface it wraps: it derefs to the handle stored in its field — which is how the default scheme’s authenticate(...) becomes api.authenticate(...) — and emits the delegating interface impls.
  • flow::<S>() on the interface reaches every other scheme, and as_role("…").flow::<S>() reaches one at a named tier.

The layer-1 header carriers

At the bottom are the credential types the Token and Basic schemes and the add-on flows produce, all of which redact their secret in Debug and mark the header sensitive. Each hands the store a scheme_prefix() and a token() — the split the entry keeps:

  • BearerAuthHeader::new(token)Authorization: Bearer <token>.
  • BasicAuthHeader::new(username, password)Authorization: Basic <base64> (behind the facade’s auth-basic feature).
  • AuthorizationHeader::new(prefix, value) → the general Authorization: <prefix> <value>, used for custom schemes and API keys.

The config side of this — the ApiConfig::auth_store override, AuthConfigExt, and the new_with_auth_header custom-header path — you already stood up in the config chapter. The next chapter wires the entry point that fills the store.