ADR 0021: Pluggable Authentication: the AuthProvider Seam
Status
Proposed, 2026-10-28. It is the first child story of epic cratestack#1243. Nothing here is a supported surface until this ADR is accepted, except what cratestack#1246 builds (open, not yet merged): the refusal envelope on REST, RPC, SSE, middleware errors, MCP and sync (§1, cratestack#1208), and MCP’s pluggable credential-presence rule (§4). §1 and §4 record what that PR does; the rest of this ADR decides what comes next. The number is the next free one across both repositories. 0007–0010 are reserved by ADR 0001, 0011–0019 are the framework’s internal-shape series, and 0020 is the sync engine. The “ADR 0032” and “ADR 0038” that appear in framework comments belong to an external (Webank) series, not this one. This ADR lives here rather than in the framework because the refusal envelope, the claim names and the canonical request are all visible to clients and to authors of auth libraries.Date
- 2026-10-28: proposed. The refusal envelope (§1) is built for REST, RPC unary and RPC batch.
- 2026-10-29: updated for cratestack#1246, which also renders refusals on MCP Streamable HTTP and sync,
fixes sync’s leak of
Internaldetail, and builds MCP credential presence as a builder option (CredentialPresence) rather than the trait method first proposed in §4.
The standing decision this ADR serves
The maintainer decided on 2026-10-28 that CrateStack does not depend on or delegate to Authkestra, or to any other authentication library. Instead, the seam must be complete enough that CrateStack’s own authentication (cratestack-auth, cratestack-cose’s auth feature) and an external library (for
example Authkestra’s authkestra-devsig or authkestra-resource) plug in equally, through the
public AuthProvider trait and nothing else. Only examples and applications depend on an external
auth library.
Every decision below follows from that. A capability the in-house providers need is either public, or
it is a bug in this seam.
Decisions for the maintainer
Context
Facts at d3c476a (0.17.0) and d8b088e (the #1208 branch)
- The trait.
AuthProviderhas one method,authenticate(&RequestContext) -> Result<CratestackContext, Self::Error>, withtype Error: Into<CratestackError>(context.rs). A header-only closure implements it through a blanket impl. - The request.
RequestContextcarriesmethod,path,query,headers, the full bufferedbodyand the in-processextensions(trusted, unlike headers). Body-hash schemes (bdh-style device signatures, SigV4-style content hashes) can therefore verify today. - The context.
CratestackContextholds anauthidentity (a flat field map) and aprincipal(actor,sessionandtenantfacets plus a claims map). Both are untyped.auth_field(path)resolves dotted paths across both. Two private, non-serializable facts sit next to them: the system flag (SystemContext) and theVerifiedSigneran envelope recorded (ADR 0006 §12). Neither can be set by a provider. - Policies read conventions, not a contract.
hasRole(r)readsrole, thenactor.role, and compares one string.inTenant(t)readstenant.idas a string (eval.rs).auth().<path>is resolved at compile time against the schema’sauthblock and read withauth_fieldat run time. Framework code reads the caller’s id in at least three ways:principal_actor_id()(actor.id, thenclaims.id, thenauth.id, strings only), MCP’s admission key (principal_id, which also renders an integeridas its digits, so thatauth User { id Int }schemas work), and the WebTransport session key in #1238 (actor.id, thenid, thensub). COSE’s signer principal writesid = "cose:<thumbprint>".cratestack-syncasks an application-supplied resolver. - The only shipped mapper is product-specific.
cratestack-auth’sprincipal_to_cratestack_contextemitsactor { id, enrollmentStatus, kycStatus, … },session { clientId, boundKeyId, requestNonce, … },id,clientId,role,kycStatus,kycDossierId. An external library has nothing documented to map to. - Refusals. Before #1208,
into_response()always setdetails: Noneand a provider could only choose betweenUNAUTHORIZEDandFORBIDDENwith a message. Clients that sort refusals by reason (fespalier’s device-signature classifier:attestation_expiredmeans renew,key_not_boundmeans signed out,DEVICE_REVOKEDmeans revoked; OAuth’sinvalid_tokenandinsufficient_scope) could not work through the generated router. fespalier’sexamples/cratestackrefuses in a tower layer in front of the router instead. - Each transport calls the provider its own way. REST and RPC unary authenticate per request.
RPC batch authenticates the envelope once and hands every frame a
CachedAuthProvider. SSE model subscriptions authenticateGET /rpc/subscribe/<op_id>once, before the stream. MCP Streamable HTTP requiresAuthorization: Bearerbefore the provider runs (streamable_http/auth.rs).cratestack-syncturns any provider error into401 unauthenticated(server/http/mod.rs). The WebTransport binding in cratestack#1238 (open) authenticates once per session. - Composition is hand-written.
CoseAuthProviderwraps an inner provider: the inner one runs first, its authenticated answer or its error is returned as is, and only an anonymous answer falls back to the verified signer (signer_provider.rs).
Goals and non-goals
Goals: one seam, identical behaviour on every transport, nothing private for in-house providers, and a test kit an external provider can run. Not goals, as in the epic:- a built-in OAuth or OIDC server, or a user store;
- schema grammar for freshness or step-up. That is cratestack#1244, which builds on §1 and §2;
- any change to the COSE envelope wire format (ADR 0006);
- any dependency of a framework crate on an auth library.
Decision
1. The refusal envelope (D1)
As built in #1208
A provider refuses withErr(AuthRefusal::unauthenticated(msg).with_member(..).into_error()):
The source is
error/auth_refusal.rs.
Five properties of the built design are decisions this ADR records.
- The refusal becomes
CratestackError::AuthRefusedthroughinto_error(), and there is deliberately noFrom<AuthRefusal> for CratestackError. A secondFromimpl onCratestackErrormakes every?whose error type was inferred through the reflexiveFrom<T> for Tambiguous (E0283), in generated code and in application code alike. The trait’stype Error: Into<CratestackError>is unchanged, so no existing provider breaks. - The
codestays framework-owned. The epic’s first sketch let a provider choose the wirecode. The built design does not: a client that switches onUNAUTHORIZEDorunauthenticatedkeeps working, and a provider’s own reason travels as a member. By convention that member is namederror, the name RFC 6750 uses and fespalier’s classifier reads ({"code": "UNAUTHORIZED", "message": "attestation expired", "error": "attestation_expired"}). The convention is documented, not enforced. - Only 401 and 403. A refusal is about who the caller is. A provider that cannot decide (a JWKS
endpoint is down) returns
UnavailableorInternal, which keep their canned public messages. A refusal is never a 5xx. - No byte changes for anyone else.
CratestackErrorResponseandRpcErrorBodygain a publicextramap, serialized by hand as a known-length map, never#[serde(flatten)](which a CBOR encoder writes as an indefinite-length map). An envelope without extra members encodes byte for byte as before, in JSON and in CBOR (cratestack-codec-cbor/tests/error_envelope_bytes.rs). Unknown members of an envelope read from the wire are collected intoextra. - Challenges ride on the error, not in the envelope. Every encoder takes them off the error before
it is consumed and appends them to the response
(
auth_challenge.rs). The RPC binding re-encodes a handler’s REST-shaped error into an RPC body and copies the header across. The streaming (@stream) and middleware error paths attach them too.
crates/cratestack-api/tests/auth_refusal_rest.rs and auth_refusal_rpc.rs (a member and a
challenge on 401, details on 403, reserved names not replaceable, a plain Unauthorized unchanged,
and the batch case).
The transport matrix
“Today” isd8b088e, with #1238’s branch for WebTransport. “Target” is what this ADR decides.
Sources for the WebTransport rows: #1238 and
docs/design/subscriptions-websocket.md §6.3, §6a.2 and
§6a.4 on its branch, plus the statement of that PR’s author for this ADR. This ADR does not change #1238.
A frame has no header, so challenges cannot reach a WebTransport or WebSocket client today. Whether
RpcErrorBody should carry them is Q5.
2. The principal contract (D2)
Reserved names
These top-level claim names are reserved. A provider that sets one must give it the listed type. Any other name stays free-form and keeps today’s behaviour. The names follow the JWT and OIDC registries in their registered spelling (auth_time, not authTime), even though cratestack-auth’s product claims
are camelCase.
id is not reserved. Many schemas declare auth SessionUser { id Int … }, and reserving it with a
fixed type would break them. It stays a free-form, schema-typed claim, and the framework’s own “who”
reads fall back to it (below).
A value of the wrong type reads as absent. Every reader therefore fails closed: hasRole on a roles
that is not a list of strings is false, and so is inTenant on a numeric tenant.id.
A builder and typed readers in core
cratestack-core gains a builder, so a provider does not have to know the facet layout:
auth and principal the existing constructors fill, so auth().<path> and every
existing reader see the result. It sets no private fact: the system flag and the verified signer stay
out of a provider’s reach.
Typed readers on CratestackContext return None for an absent or mistyped claim: subject(),
roles(), auth_time(), amr(), acr(), cnf_jkt(), key_role() and expires_at(). The exact
names are left to the implementation.
How the readers use it
subject()readssub, thenactor.id, thenid, and renders an integer as its decimal digits (MCP’sprincipal_idrule). Every framework read of “who” moves to it:principal_actor_id(), the MCP admission key, and the WebTransport session key and principal-change check.cratestack-sync’s principal resolver defaults to it. Today these readers disagree on the order and on integer ids. A provider that setssubgets the same answer everywhere.hasRole(r)is true whenrolescontainsr, orroleequalsr, oractor.roleequalsr. The two legacy reads stay, so no existing policy changes meaning.inTenant(t)readstenant.id, unchanged.auth().<path>is unchanged: the schema’sauthblock declares what a policy may read. A schema that declares a reserved name must declare it with the contract’s type (for exampleroles String[]), and the semantic checker says so. The policy operators that compareauth_timeor test membership inamrare #1244’s grammar, not this ADR’s.
The product-specific mapping in cratestack-auth
principal_to_cratestack_context keeps its current shape, because Webank’s policies read it
(auth().actor.id, kycStatus, session.boundKeyId). The change is additive:
- It also emits the contract names it knows:
sub(the user id, orsvc:<key id>for a service caller),roles(from the verifiedroleclaim), andcnffrom the id-token’s key binding.auth_timeandamrare #1244’s child story 8. - A second mapper emits only the contract names plus the free-form claims a deployment selects, for applications that are not Webank.
SignedRequestAuthProvidertakes the mapper as configuration. Which one is the default is Q2.
CoseAuthProvider’s signer principal moves to the builder too: sub is cose:<thumbprint> (the
string its id already carries, and the envelope layer’s default rate-limit principal), and the
COSE key thumbprint goes into cnf (Q7).
3. Canonical request versus wire request (D3)
A provider that verifies a signature needs to know exactly which request it is given. Today that is true by construction in the generated code, but it is written down only in code comments, and one caller (sync) passes the wire path instead. The rule. TheRequestContext a provider receives is the canonical request of the operation,
and a client signs the same thing:
methodis the operation’s declared method, not whatever the client sent:POSTfor every RPC call,GETfor a subscription,CONNECTfor a WebTransport session.pathis the route CrateStack declares, relative to the router’s own root. A mount prefix never appears: a router nested under/apihands the provider/rpc/<op_id>, not/api/rpc/<op_id>. A client signs its request path minus the base URL’s path. The internal/$procs/...path never appears ontransport rpceither.queryis the raw query string exactly as received, where the operation reads one (REST model reads, and a WebTransport CONNECT, whose?codec=cbor|jsonis the fallback for a client that cannot negotiate the subprotocol), andNoneeverywhere else.bodyis the exact bytes the router received, before decoding, and never re-encoded. Behind the COSE envelope layer that is the opened payload. A provider that needs the sealed message reads the recordedVerifiedSigner, not the body.headersandextensionsare the request’s own. A synthesized request (a WebTransportInit) says in its transport’s documentation which headers it substitutes.
Three call sites change to meet it: sync passes
parts.uri.path() and should pass its constant route;
WebTransport passes an empty extensions and should pass at least the peer address; and the REST model
/<id> segment is the decoded id, not the wire bytes (Q6). WebTransport’s path already meets the
rule: a CONNECT to any other path is refused with 404 before authentication, and the binding is its
own QUIC listener, so there is no mount prefix to strip.
4. MCP credential presence (D4)
Today MCP requiresAuthorization: Bearer before the provider runs, so a device-signature or COSE
provider can never authenticate an MCP call. The Bearer check does three things worth keeping: a
missing credential gets a 401 challenge with no error (RFC 6750 §3.1), a malformed one gets a 400
invalid_request, and the token is redacted from logged provider errors.
As built in #1246: a builder option
MCP credential presence is chosen onStreamableHttpServer::with_credential_presence:
A request found without a credential still gets the Bearer challenge with
resource_metadata. Two
Authorization headers are still a 400 under every rule. The challenge carries an error only when
the request actually sent a bearer token.
This was chosen over the trait method below for the first slice because it touches only MCP, changes
no trait, and lets a deployment use a provider it does not own (an external library’s) without
wrapping it. Its limit: the provider cannot say why a credential is malformed (Malformed below),
and nothing outside MCP can ask a provider whether it has a credential.
Still proposed: a defaulted trait method, for the chain (D5)
The first-success chain (§5) must skip a provider that has no credential without calling it, which needs the provider, not the deployment, to answer. SoAuthProvider would gain a defaulted method:
Present when its
signature header is there; CoseAuthProvider answers Present when a verified signer is in the
extensions.
ADR 0002’s posture is kept. ADR 0002 makes the MCP endpoint an OAuth 2.1 resource server: a 401
with WWW-Authenticate naming resource_metadata, Protected Resource Metadata, an identity required
after authentication, and no token in the query. Accepting a non-Bearer credential departs from the MCP
authorization specification, so it is opt-in:
- By default the MCP service uses
Credential::bearerwhatever the provider says. The MCP conformance job keeps testing a spec-conformant server. - A deployment that opts in on the Streamable HTTP builder asks
provider.credential(..)instead.Absentstill gets the Bearer challenge withresource_metadata, so OAuth clients still discover the authorization server;Malformedis a 400invalid_request;Present { redact }runs the provider and redacts that string from logged detail. - An anonymous answer is still a 401, the query-token refusal stays, and refusals render as in §1.
CredentialPresence::provider_decides() can ask provider.credential(..) before the body is
read, instead of always calling authenticate, and the builder option stays as the deployment’s
override.
5. Composition (D5)
cratestack-core gains a first-success chain, replacing hand-written wrappers such as
CoseAuthProvider’s:
- If
credential()saysAbsent, skip it without callingauthenticate. A strict provider that refuses a request without its credential can therefore sit in a chain. - If it returns an authenticated context, return it.
- If it returns an error (a refusal or otherwise), return that error. A credential one provider
refuses is never rescued by another: that is
CoseAuthProvider’s rule 1, and it fails closed. - If it returns an anonymous context, go on to the next.
credential() is Present if any member’s is, carrying the first member’s redact. It is
generic and static (nested Chain<A, B> types, no dyn), because AuthProvider returns
impl Future and is not object-safe. A request that presents two credentials is resolved by order
(Q8).
CoseAuthProvider is rewritten as first_success(inner).or(signer_provider), which is the epic’s
“in-house auth uses only the public seam” in practice.
6. The conformance kit (D6)
A provider author, in-house or external, writes one test:@stream procedure path, MCP Streamable HTTP (feature
mcp), and, with a database, SSE model subscriptions and sync. WebTransport and WebSocket join when
their bindings ship. It mounts each router both at the root and under a prefix. It asserts:
- the same verdict on every transport: an accepted credential yields the same
subject(), and a refused one the same refusal; - the same rendering: status, RPC or REST code, message, members,
detailsand challenges, per the matrix in §1 (whole-batch for batch, the MCP challenge first on MCP); - the canonical request of §3 exactly, including the body bytes and the absence of the mount prefix;
- one call per request: a batch is authenticated once, and a skipped chain member is not called;
- nothing leaks: an
Internalerror from the provider stays canned, and a refusal’s members can never replacecode,messageordetails.
testing feature on the cratestack-pg and cratestack-api facades, the crates a
provider’s tests already depend on, so that a dev-dependency switches it on and nothing ships in a
release graph. Where its code lives (a new crate the feature pulls in, or a module of an existing
one) is Q9. Database-backed cases follow the repository’s PG-test convention: they skip without
CRATESTACK_TEST_DATABASE_URL, and fail instead under CRATESTACK_REQUIRE_DB=1.
SignedRequestAuthProvider and CoseAuthProvider must pass it. So must the external-library example
(§7).
7. The independence guarantee (D7)
No framework crate depends on any authentication library, directly or transitively, under any feature. The seam’s types (AuthRefusal, the principal contract, Credential, the chain) live in
cratestack-core, which respects ADR 0013 (facade disjointness) and ADR 0014 (layer direction);
in-house auth stays at L1 and depends only on core.
The proof follows examples/client-only-verification:
- The example that wires an external library is excluded from the workspace, with its own
[workspace]root and lockfile, so Cargo’s feature unification cannot hide anything. The epic’s child story 10 is superseded by cratestack#1245’sexamples/auth-pluggable(one schema, three providers: in-house, Authkestra devsig, Authkestra OIDC), so the two are built once. - A CI job asserts, from the workspace root, that no workspace member’s dependency graph contains an
authkestrapackage (cargo metadataover every member, andcargo tree --workspace -iper known package name, which must find nothing), and, from the example’s root, that it does. The second assertion keeps the first from passing because the example quietly stopped using the library. - The example uses only public facade paths: a grep of its
src/for__privatemust find nothing. - The example runs the conformance kit.
deny.toml is the alternative if more libraries need guarding.
Consequences
Benefits:- A code-classifying or non-Bearer scheme needs no tower layer in front of the router.
- An external library and the in-house providers use the same public surface, proven by one kit.
- Every framework read of “who” agrees, and policies keep their meaning.
- #1244 gets the claims (
auth_time,amr,acr,key_role) and the refusal channel it needs.
- Two new public concepts in core (
Credential, the builder) and one defaulted trait method. - Reserved names constrain what a schema’s
authblock may declare under those names. - The kit must grow with every transport. A transport it does not drive is a transport where parity is unproven.
- The sync error body grows a
forbiddencode and members, which is a sync protocol change.
Alternatives considered
Open questions for the maintainer
Implementation plan
The epic’s child stories, in dependency order:- This ADR.
AuthRefusal, REST and RPC unary: built (#1208, in #1246).- RPC batch, SSE, middleware and sync rendering (§1): built in #1246.
- MCP refusal rendering and
CredentialPresence(§1, §4): built in #1246. The defaultedAuthProvider::credentialmethod moves to story 7, with the chain that needs it. - The principal contract, builder and readers;
hasRoleoverroles(§2). - The canonical-request rule and its tests; the sync and WebTransport call sites (§3).
- The first-success chain (§5), with the defaulted
AuthProvider::credentialmethod (§4). - The conformance kit (§6).
SignedRequestAuthProviderandCoseAuthProvideronto the public seam, passing the kit.- The independence guard, with #1245’s
examples/auth-pluggable(§7). - Generated clients: the refusal’s members and
detailstyped onCratestackRestExceptionandCratestackRpcException, in Rust, Dart and TypeScript together (transport parity).
### entry under the framework’s ## Unreleased declares its docs and skills
companions (cratestack-docs#139,
cratestack-skills#41).