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 Internal detail, 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)

  1. The trait. AuthProvider has one method, authenticate(&RequestContext) -> Result<CratestackContext, Self::Error>, with type Error: Into<CratestackError> (context.rs). A header-only closure implements it through a blanket impl.
  2. The request. RequestContext carries method, path, query, headers, the full buffered body and the in-process extensions (trusted, unlike headers). Body-hash schemes (bdh-style device signatures, SigV4-style content hashes) can therefore verify today.
  3. The context. CratestackContext holds an auth identity (a flat field map) and a principal (actor, session and tenant facets 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 the VerifiedSigner an envelope recorded (ADR 0006 §12). Neither can be set by a provider.
  4. Policies read conventions, not a contract. hasRole(r) reads role, then actor.role, and compares one string. inTenant(t) reads tenant.id as a string (eval.rs). auth().<path> is resolved at compile time against the schema’s auth block and read with auth_field at run time. Framework code reads the caller’s id in at least three ways: principal_actor_id() (actor.id, then claims.id, then auth.id, strings only), MCP’s admission key (principal_id, which also renders an integer id as its digits, so that auth User { id Int } schemas work), and the WebTransport session key in #1238 (actor.id, then id, then sub). COSE’s signer principal writes id = "cose:<thumbprint>". cratestack-sync asks an application-supplied resolver.
  5. The only shipped mapper is product-specific. cratestack-auth’s principal_to_cratestack_context emits actor { id, enrollmentStatus, kycStatus, … }, session { clientId, boundKeyId, requestNonce, … }, id, clientId, role, kycStatus, kycDossierId. An external library has nothing documented to map to.
  6. Refusals. Before #1208, into_response() always set details: None and a provider could only choose between UNAUTHORIZED and FORBIDDEN with a message. Clients that sort refusals by reason (fespalier’s device-signature classifier: attestation_expired means renew, key_not_bound means signed out, DEVICE_REVOKED means revoked; OAuth’s invalid_token and insufficient_scope) could not work through the generated router. fespalier’s examples/cratestack refuses in a tower layer in front of the router instead.
  7. 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 authenticate GET /rpc/subscribe/<op_id> once, before the stream. MCP Streamable HTTP requires Authorization: Bearer before the provider runs (streamable_http/auth.rs). cratestack-sync turns any provider error into 401 unauthenticated (server/http/mod.rs). The WebTransport binding in cratestack#1238 (open) authenticates once per session.
  8. Composition is hand-written. CoseAuthProvider wraps 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 with Err(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::AuthRefused through into_error(), and there is deliberately no From<AuthRefusal> for CratestackError. A second From impl on CratestackError makes every ? whose error type was inferred through the reflexive From<T> for T ambiguous (E0283), in generated code and in application code alike. The trait’s type Error: Into<CratestackError> is unchanged, so no existing provider breaks.
  • The code stays framework-owned. The epic’s first sketch let a provider choose the wire code. The built design does not: a client that switches on UNAUTHORIZED or unauthenticated keeps working, and a provider’s own reason travels as a member. By convention that member is named error, 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 Unavailable or Internal, which keep their canned public messages. A refusal is never a 5xx.
  • No byte changes for anyone else. CratestackErrorResponse and RpcErrorBody gain a public extra map, 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 into extra.
  • 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.
Tests: 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” is d8b088e, 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:
It fills the same 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() reads sub, then actor.id, then id, and renders an integer as its decimal digits (MCP’s principal_id rule). 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 sets sub gets the same answer everywhere.
  • hasRole(r) is true when roles contains r, or role equals r, or actor.role equals r. The two legacy reads stay, so no existing policy changes meaning.
  • inTenant(t) reads tenant.id, unchanged.
  • auth().<path> is unchanged: the schema’s auth block declares what a policy may read. A schema that declares a reserved name must declare it with the contract’s type (for example roles String[]), and the semantic checker says so. The policy operators that compare auth_time or test membership in amr are #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:
  1. It also emits the contract names it knows: sub (the user id, or svc:<key id> for a service caller), roles (from the verified role claim), and cnf from the id-token’s key binding. auth_time and amr are #1244’s child story 8.
  2. A second mapper emits only the contract names plus the free-form claims a deployment selects, for applications that are not Webank.
  3. SignedRequestAuthProvider takes 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. The RequestContext a provider receives is the canonical request of the operation, and a client signs the same thing:
  1. method is the operation’s declared method, not whatever the client sent: POST for every RPC call, GET for a subscription, CONNECT for a WebTransport session.
  2. path is the route CrateStack declares, relative to the router’s own root. A mount prefix never appears: a router nested under /api hands 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 on transport rpc either.
  3. query is the raw query string exactly as received, where the operation reads one (REST model reads, and a WebTransport CONNECT, whose ?codec=cbor|json is the fallback for a client that cannot negotiate the subprotocol), and None everywhere else.
  4. body is 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 recorded VerifiedSigner, not the body.
  5. headers and extensions are the request’s own. A synthesized request (a WebTransport Init) says in its transport’s documentation which headers it substitutes.
Per transport: 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 requires Authorization: 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 on StreamableHttpServer::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. So AuthProvider would gain a defaulted method:
Adding a defaulted method is not a breaking change, and the default is exactly today’s check, so no existing provider changes behaviour anywhere. A device-signature provider answers 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::bearer whatever 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. Absent still gets the Bearer challenge with resource_metadata, so OAuth clients still discover the authorization server; Malformed is a 400 invalid_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.
This touches MCP deliberately, as ADR 0002 D5 requires for any extension of MCP. Once the method exists, 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:
The rules, in order, for each member:
  1. If credential() says Absent, skip it without calling authenticate. A strict provider that refuses a request without its credential can therefore sit in a chain.
  2. If it returns an authenticated context, return it.
  3. 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.
  4. If it returns an anonymous context, go on to the next.
If no member authenticates, the chain answers anonymous and policy decides. The chain’s own 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:
The kit compiles its own small schema and drives the provider through every transport the enabled features offer: REST, RPC unary, RPC batch, the @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, details and 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 Internal error from the provider stays canned, and a refusal’s members can never replace code, message or details.
It ships behind a 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’s examples/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 authkestra package (cargo metadata over every member, and cargo tree --workspace -i per 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 __private must find nothing.
  • The example runs the conformance kit.
The guard names Authkestra because it is the library in the programme. Its rule is general, and a deny-list in 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.
Costs:
  • Two new public concepts in core (Credential, the builder) and one defaulted trait method.
  • Reserved names constrain what a schema’s auth block 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 forbidden code 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:
  1. This ADR.
  2. AuthRefusal, REST and RPC unary: built (#1208, in #1246).
  3. RPC batch, SSE, middleware and sync rendering (§1): built in #1246.
  4. MCP refusal rendering and CredentialPresence (§1, §4): built in #1246. The defaulted AuthProvider::credential method moves to story 7, with the chain that needs it.
  5. The principal contract, builder and readers; hasRole over roles (§2).
  6. The canonical-request rule and its tests; the sync and WebTransport call sites (§3).
  7. The first-success chain (§5), with the defaulted AuthProvider::credential method (§4).
  8. The conformance kit (§6).
  9. SignedRequestAuthProvider and CoseAuthProvider onto the public seam, passing the kit.
  10. The independence guard, with #1245’s examples/auth-pluggable (§7).
  11. Generated clients: the refusal’s members and details typed on CratestackRestException and CratestackRpcException, in Rust, Dart and TypeScript together (transport parity).
Each story that adds a ### entry under the framework’s ## Unreleased declares its docs and skills companions (cratestack-docs#139, cratestack-skills#41).