Skip to content

Auth Provider ​

CrateStack does not authenticate requests itself.

The host application is responsible for:

  • reading headers, cookies, bearer tokens, signed requests, or other transport-specific auth material
  • validating that material
  • projecting the authenticated principal into a CrateStack auth context

The framework boundary for that is cratestack::AuthProvider.

Trait shape ​

Implement one trait per host auth strategy:

rust
use cratestack::{AuthProvider, CratestackContext, RequestContext};

#[derive(Clone)]
struct AppAuthProvider;

impl AuthProvider for AppAuthProvider {
    type Error = cratestack::CratestackError;

    fn authenticate(
        &self,
        request: &RequestContext<'_>,
    ) -> impl core::future::Future<Output = Result<CratestackContext, Self::Error>> + Send {
        let result = resolve_context_from_request(request);
        core::future::ready(result)
    }
}

CratestackContext and RequestContext are two distinct types, easy to conflate by name alone: RequestContext is the inbound view (method/path/headers/body) the provider reads from, while CratestackContext is the outbound auth/principal result the provider produces. authenticate(...) takes one and returns the other.

RequestContext gives the provider a framework-owned request view:

  • method
  • path
  • query
  • headers
  • body

This keeps auth resolution outside generated handlers while avoiding direct dependency on a specific host framework request type.

Router usage ​

Register the provider once when building generated routes:

rust
let router = cratestack_schema::axum::router(
    db,
    procedures,
    CborCodec,
    AppAuthProvider,
);

Generated CRUD and procedure routes will call authenticate(...) for each request and use the resulting CratestackContext for schema policy enforcement.

Refusing a request ​

(Since 0.17.1, cratestack#1208.) A provider refuses by returning an error. CratestackError::Unauthorized and Forbidden give the standard envelope and nothing more:

json
{ "code": "UNAUTHORIZED", "message": "no credential", "details": null }

That is enough for a client that only needs "signed in or not". When the client must act on why (renew a credential and retry once, sign out and wipe its keys, re-authenticate more strongly), return an AuthRefusal instead:

rust
use cratestack::{AuthRefusal, Value};

return Err(AuthRefusal::unauthenticated("attestation expired")
    .with_member("error", Value::String("attestation_expired".into()))
    .with_challenge(r#"Signature error="attestation_expired""#)
    .into_error());
  • into_error() turns the refusal into the CratestackError the provider returns. There is no From conversion, so ? and .into() do not apply.
  • AuthRefusal::unauthenticated(message) is a 401 and AuthRefusal::forbidden(message) a 403. The code stays UNAUTHORIZED / FORBIDDEN (RPC: unauthenticated / permission_denied), so a client that ignores the rest reads the refusal as before.
  • with_member(name, value) adds a top-level member next to code and message. The names code, message and details are ignored: a refusal cannot replace them.
  • with_details(value) sets details.
  • with_challenge(value) adds a WWW-Authenticate header (an RFC 6750 or RFC 9470 challenge, for instance). A value that is not printable ASCII is ignored.

The REST response for the refusal above:

http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Signature error="attestation_expired"

{ "code": "UNAUTHORIZED", "message": "attestation expired", "details": null, "error": "attestation_expired" }

RPC sends the same status, members and header, with the RPC code ({"code": "unauthenticated", ...}). A POST /rpc/batch is authenticated once, before any frame runs, so a refusal answers the whole batch the same way.

The Rust types gained a field for this: CratestackErrorResponse and RpcErrorBody carry the members in extra, and an envelope read from the wire collects there every member it does not know. An envelope without extra members is encoded byte for byte as before, in JSON and in CBOR.

Reading a refusal in a generated client ​

(Since 0.17.1, cratestack#1243.) Every generated client types the same five things on both transports: the wire code, message, details, every other member (extra, never dropped, CBOR or JSON) and the WWW-Authenticate challenges (raw, unparsed).

ClientREST errorRPC error
RustClientError::Remote: remote_error() (the CratestackErrorResponse: code, message, details, extra) and challenges()RpcClientError::Remote(RpcRemoteError): body (RpcErrorBody: code, message, details, extra) and challenges; RpcClientError::challenges()
DartCratestackRestException: code, message, details, extra, challenges, headers (and the raw body)CratestackRpcException: code, message, details, extra, challenges, headers; RpcErrorBody.extra
TypeScriptCratestackHttpError: body (CratestackErrorResponse), code, details, extra, challenges (and the raw payload)CratestackRpcError: code, details, body, extra, headers, challenges
dart
try {
  await client.accounts.get(7);
} on CratestackRpcException catch (e) {
  if (e.code == CratestackRpcErrorCodes.unauthenticated &&
      e.extra['error'] == 'attestation_expired') {
    // renew the credential and send again, once
  }
}

Two refusal lines of WWW-Authenticate reach Rust as two values. The Dart HTTP seam and the browser's Headers join a repeated header with , , so Dart and TypeScript see one value holding both. An error inside a batch frame or a stream's final item has no headers of its own, so its challenges is empty. The --rtk REST error (RtkRestQueryError) carries code, extra and challenges; --swr hooks surface the same error instances. In Rust, ClientError::Remote and RpcRemoteError gained a challenges field, so a pattern that names every field without .. needs one.

A step-up refusal in a generated client ​

(Since 0.17.1, cratestack#1244.) An operation can require a fresher or stronger authentication than the caller presented. The server refuses it with an AuthRefusal: 401 on REST (code UNAUTHORIZED), code unauthenticated on RPC (unary and per batch frame), the extra member error: "insufficient_user_authentication" (RFC 9470) and a details object:

json
{
  "code": "UNAUTHORIZED",
  "message": "step-up authentication required",
  "details": { "max_age": 300, "acr_values": ["aal2", "aal3"], "unmet": ["max_age"] },
  "error": "insufficient_user_authentication"
}

max_age (seconds), acr_values and amr appear only when the operation requires them; unmet names which of max_age, acr and amr the caller failed. REST also sends WWW-Authenticate: Bearer error="insufficient_user_authentication", .... A procedure declares the requirement with @authn, and the generated server sends this refusal on every path to it; Step-up examples runs the whole path.

Every generated client types the refusal as StepUpRequired. An error is a step-up only when its status is 401 (REST) or its code unauthenticated (RPC) and its error member is insufficient_user_authentication; anything else reads as none. Reading never throws: a missing or mistyped field reads as absent or empty.

ClientThe refusalRetry once after a re-proof
Rust (cratestack_client_rust, or cratestack::client_rust through a facade)StepUpRequired { max_age: Option<u64>, acr_values, amr, unmet: Vec<String> }; ClientError::step_up(), RpcClientError::step_up(), RpcRemoteError::step_up() (a batch frame's error), the StepUpError trait over the threeretry_after_step_up(call, reprove)
Dart (every preset)StepUpRequired (maxAge, acrValues, amr, unmet); stepUp on CratestackRestException and CratestackRpcException, null when not a step-upretryAfterStepUp(call, reprove)
TypeScriptStepUpRequired (maxAge, acrValues, amr, unmet); stepUp on CratestackHttpError and CratestackRpcError; stepUpOf(error), which in the RPC runtime also reads a batch frame's error bodyretryAfterStepUp(call, reprove)

The retry helper runs call. When it fails with a step-up, it hands the refusal to reprove, your re-authentication. When reprove answers true, it runs call once more and returns whatever that gives, a second refusal included: it never loops. When reprove answers false (the user declined, or the re-proof failed), the first refusal is returned (Rust) or rethrown (Dart, TypeScript). Any other error propagates unchanged and never asks for a re-proof.

dart
final receipt = await retryAfterStepUp(
  () => api.procedures.transfer(args),
  (stepUp) => session.reauthenticate(maxAge: stepUp.maxAge),
);
rust
let receipt = retry_after_step_up(
    || client.procedures().transfer(&args),
    |step_up| async move { session.reauthenticate(step_up.max_age).await.is_ok() },
)
.await?;

In Rust, call may return anything .await takes, so a generated RPC method's call fits as is. The riverpod providers store the exception itself in AsyncError, so stepUp reads the same there; the fespalier preset's refusals reach the same exception classes. The --rtk REST error (RtkRestQueryError) gains an optional stepUp, and --swr hooks surface the same error instances.

A retry under the same Idempotency-Key

On 0.17.1 the idempotency layer records a 401 like any other error, the step-up refusal included, and replays it to the next request under the same key. A retry that resends the refused call with the key it was first sent with therefore gets the stored 401 back instead of running. The same holds for a retry after renewing an expired credential. A call without an Idempotency-Key is not affected.

(Unreleased, cratestack#1261.) A refusal the generated dispatch produces before any procedure body runs is no longer recorded: the provider's (authenticate), @allow / @deny's and @authn's. The layer releases the key, so the retry under the same key runs the call once. See Refusals before the body are not recorded.

The MCP transport renders a refusal the same way (status, members, details, challenges after its own resource_metadata one); see MCP. So do the prototype sync endpoints, whose error body carries the members next to its code, with a new forbidden code for a 403.

What the provider is given ​

(Since 0.17.1, cratestack#1243.) The RequestContext is the canonical request of the operation. A provider that verifies a signature can rely on it, and a client signs the same thing:

Transportmethodpathquerybody
REST procedurePOSTthe declared route, e.g. /$procs/whoAmINonethe wire bytes
REST modelthe route's methodthe declared route, with the decoded /<id>the raw querythe wire bytes, or empty
RPC unaryPOST/rpc/<op_id>Nonethe raw frame bytes
RPC batchPOST/rpc/batchNonethe whole batch body, authenticated once
SSE subscriptionGET/rpc/subscribe/<op_id>Noneempty
MCP Streamable HTTPPOSTthe path of the resource identifierthe raw querythe wire bytes
syncthe route's method/sync/register, /sync/push, /sync/pull or /sync/pokethe raw querythe wire bytes

A mount prefix never appears. A router nested under /api hands the provider /rpc/<op_id>, not /api/rpc/<op_id>, so a client signs its request path minus its base URL's path. The body is the bytes the router received, before decoding and never re-encoded; behind a COSE envelope layer it is the opened payload.

Saying whether a request carries a credential ​

(Since 0.17.1, cratestack#1243.) A provider may also say, without authenticating, whether a request carries its kind of credential:

rust
use cratestack::{AuthProvider, Credential, RequestContext};

impl AuthProvider for DeviceSignatureProvider {
    // type Error and authenticate(...) as above

    fn credential<'r>(&self, request: &RequestContext<'r>) -> Credential<'r> {
        Credential::header(request.headers, "x-signature")
    }
}
  • Credential::Unknown is the default when a provider does not implement the method: it is always called, as before.
  • Credential::Absent: the request carries none of this provider's credentials.
  • Credential::Present { redact }: it carries one. redact is a value to cut out of logged error detail, such as a token.
  • Credential::Malformed(reason): it carries one the provider will not read.
  • Credential::bearer(headers) reads an Authorization: Bearer token as MCP does, and Credential::header(headers, name) a credential carried in one header.

The answer is read from the request head. The body may be empty when the provider is asked, so a provider never looks for its credential there. Two things ask: a first-success chain, and MCP under CredentialPresence::ask_provider().

Composing providers ​

(Since 0.17.1, cratestack#1243.) An application that accepts more than one kind of credential composes its providers into one:

rust
use cratestack::first_success;

let auth = first_success(oauth).or(device_signature).or(cose_signer);
let router = cratestack_schema::axum::router(db, procedures, CborCodec, auth);

Each member is asked in order:

  1. If its credential is Absent, it is skipped without being called.
  2. If it returns an authenticated context, that is the answer.
  3. If it returns an error, that is the answer. A credential one member refuses is never rescued by another: the chain fails closed.
  4. If it returns an anonymous context, the next member is asked.

When no member authenticates, the chain answers with the first anonymous context a member returned (so its extensions, such as a client IP, are kept), and the schema's policies decide. A request that carries two credentials is resolved by order: the first member that authenticates it wins. A member that does not implement credential (Unknown) is always called, so put a strict provider that refuses a request without its credential after the members it should not shadow, or give it a credential method.

The chain is static (first_success(a).or(b) is a FirstSuccess<Or<A, B>>), because AuthProvider is not object-safe. Its error type is CratestackError.

Testing a provider: the conformance kit ​

(Since 0.17.1, cratestack#1243.) The cratestack-auth-conformance crate checks that a provider behaves the same on every transport. Add it as a dev-dependency and write one test:

toml
[dev-dependencies]
cratestack-auth-conformance = "0.17"
rust
use cratestack_auth_conformance::{Expected, auth_conformance};

#[tokio::test]
async fn my_provider_conforms() {
    auth_conformance(MyProvider::new(keys))
        .accepts("user-42", |canonical| sign(canonical))        // a credential it accepts
        .refuses("expired", |canonical| expired(canonical),     // a credential it refuses
                 Expected::refusal(&expected_refusal))
        .run()
        .await;
}

The kit compiles its own small schemas and sends every case through REST, RPC unary and RPC batch (each mounted at the root and under a prefix) and MCP Streamable HTTP (with CredentialPresence::ask_provider()). It fails, listing every difference, unless:

  • an accepted credential succeeds everywhere as the same subject (CratestackContext::subject());
  • a refused one reads the same everywhere: status, code, message, members, details and challenges (on MCP, the guard's own Bearer challenge comes first);
  • the provider is handed the canonical request exactly, without the mount prefix;
  • the provider is called once per request, a batch included, and not at all on MCP when its credential says Absent.

A signer is handed the Canonical request (method, path, query, body) before it is sent, so a signing provider signs exactly what it will verify. It returns headers, or Credentials with request extensions for a credential an in-process layer records, such as the COSE envelope's verified signer. Expected::refusal(&refusal), Expected::unauthorized(message) and Expected::forbidden(message) describe a refusal. On MCP a plain (non-AuthRefusal) error reads with MCP's own fixed message, so the kit compares only its status and code there.

CrateStack's own providers pass it: CoseAuthProvider (tests/in_house_cose.rs) and SignedRequestAuthProvider (tests/in_house_signed.rs), whose signer signs Canonical and so never sees the mount prefix. Both files are a starting point for a test of your own.

The kit does not drive SSE model subscriptions or the sync endpoints yet, which need a database.

For a whole service run against several providers, see examples/auth-pluggable(Since 0.17.1, cratestack#1245): one schema and one test suite (the kit, then the example's own hasRole policy) against three providers, each one file:

  • inhouse: SignedRequestAuthProvider, then a COSE-verified signer, as one chain;
  • authkestra-devsig: Authkestra's device signatures (authkestra_devsig::verify), refusing with 401 {"error": "<code>"};
  • authkestra-oidc: Authkestra's JwtStrategy with DPoP-bound access tokens, mapped onto the principal contract; its tests start authkestra-op in-process.

Its README lists what each Authkestra provider has to do itself, such as rebuilding http::request::Parts for JwtStrategy and giving a refusal its code.

Internal callers ​

Non-HTTP Rust callers can bind auth directly:

rust
let bound = db.bind_auth(Some(serde_json::json!({
    "id": 7,
    "role": "admin",
})))?;

let posts = bound.post().find_many().run().await?;

Use bind_context(...) when the host already has a CratestackContext.

Principal projection ​

bind_auth(...) accepts any serde::Serialize principal that serializes to a JSON object.

Example:

rust
#[derive(serde::Serialize)]
struct Principal {
    id: String,
    role: String,
    tenant_id: String,
}

That object is normalized into a CratestackContext principal surface with two layers:

  • a structured principal model with actor, session, tenant, and free-form claims
  • a legacy auth projection so existing auth().field policies keep working

Schema expressions can continue to use the familiar auth() shape:

cstack
@@allow("read", auth() != null && published)
@@allow("update", authorId == auth().id)
@@allow("delete", auth().role == "admin")
@@allow('all', auth() != null && auth().id == authorId)
@@deny('all', auth().organization.id != organizationId)
organizationId String? @default(auth().organization.id)

Write each rule exactly in this form, one per line: @@allow / @@deny, then (, a quoted action, a comma and the expression, with nothing after the ). Through 0.14.0 a rule written any other way (@@deny ("read", …), @@Deny(…), a trailing ;, a misspelt action) still checked as schema OK but was skipped, and so was the exact form followed by a // comment; either way the model was more permissive than written. Since 0.14.1 such a rule is refused, and a trailing // comment is stripped, so the commented form works. See Policy attribute spelling.

Structured principals can also carry first-class facets explicitly:

rust
#[derive(serde::Serialize)]
struct Principal {
    actor: Actor,
    session: Session,
    tenant: Tenant,
    role: String,
}

When those top-level keys are present, CratestackContext::from_principal(...) promotes them into the structured principal.actor, principal.session, and principal.tenant slots while still projecting the same data back through legacy auth() lookups for schema compatibility.

Current auth-derived create defaults are intentionally conservative:

  • @default(auth().field) only
  • nested auth paths like @default(auth().organization.id) are supported
  • no arbitrary expressions or function calls inside @default(...)
  • currently limited to String/Cuid, Int, and Boolean model fields

For the broader supported policy matrix, precedence rules, and explicit not-supported cases, see ../reference/auth-support-matrix.md.

The principal contract ​

(Since 0.17.1, cratestack#1243, ADR 0021 §2.) A few top-level claim names are reserved, each with a fixed type. A provider that sets one must give it that type; every other name stays free-form.

NameTypeMeaning
substringThe subject: what the framework itself uses as "who"
roleslist of stringsThe caller's roles
rolestringLegacy single role, still read
tenant.idstringThe tenant, in the tenant facet
auth_timeinteger, seconds since the epochWhen the user last actively authenticated
amrlist of stringsAuthentication methods (RFC 8176: hwk, user, pwd, otp, …)
acrstringAuthentication context class
cnfmapProof-of-possession confirmation (RFC 7800 members). cnf.jkt is the JWK SHA-256 thumbprint, base64url
key_roleambient or user_presentWhich device key proved this request
expinteger, seconds since the epochWhen the authentication expires

id is not reserved: auth User { id Int } keeps working.

Building a context ​

CratestackContext::builder(sub) builds a context from these names without knowing the facet layout:

rust
use cratestack::{CratestackContext, KeyRole, Value};

let ctx = CratestackContext::builder("user-42")         // sub
    .roles(["admin", "teller"])
    .tenant("acme")                                     // tenant.id
    .auth_time(1_793_000_000)
    .amr(["hwk", "user"])
    .acr("urn:example:loa:2")
    .cnf_jkt(jwk_thumbprint)                            // base64url
    .key_role(KeyRole::UserPresent)
    .expires_at(1_793_000_600)                          // exp
    .claim("kycStatus", Value::String("verified".into())) // free-form
    .build();
  • build() fills auth and principal exactly as CratestackContext::from_principal does for the same claims, so auth().<path> in a policy and every existing reader see the result.
  • .cnf(member, value) sets any other cnf member. .tenant(..) and the cnf setters merge into an existing map; every other setter replaces the earlier value.
  • It never makes a system context (auth().isSystem() stays false) and never records a verified signer.

Typed readers ​

Each returns None for an absent claim and for a claim of the wrong type, so a reader fails closed:

ReaderReturns
subject()Option<Cow<str>>: sub, then actor.id, then id; an integer actor.id or id as its decimal digits
roles(), amr()Option<Vec<&str>>; None if any element is not a string
auth_time(), expires_at()Option<i64>
acr(), cnf_jkt()Option<&str>
cnf(member)Option<&Value>
key_role()Option<KeyRole>

subject() is the one "who" the framework reads: the audit log's actor id, the MCP idempotency and rate-limit namespace, and the sync engine's default principal (SyncPrincipal::from_subject, cratestack_sync::server::subject_resolver). principal_actor_id() answers the same and now returns Option<Cow<str>>.

hasRole over roles ​

hasRole("r") is true when roles contains "r", or role equals "r", or actor.role equals "r". The two legacy reads stay, and each is read on its own. A roles that is not a list of strings never grants.

The auth block ​

A schema may declare a reserved name in its auth block, so a policy can read it with auth().<name>, but only with the contract's type:

cstack
auth User {
  id        Int
  roles     String[]
  auth_time Int?
}

cratestack check refuses roles String, auth_time String, cnf String, or a tenant type whose id is not a string. String, Cuid, Uuid and a string enum count as strings; a type or Json counts as a map.

cratestack-auth ​

SignedRequestAuthProvider takes the mapping from a verified principal to a context as configuration:

rust
use cratestack_auth::{PrincipalMapper, SignedRequestAuthProvider};

let provider = SignedRequestAuthProvider::new(verifier)
    .principal_mapper(PrincipalMapper::contract(["kycStatus"]));
  • PrincipalMapper::product(), the default, is today's shape (actor, session, id, role, kycStatus, …) plus the contract names: sub (the user id, or svc:<key id> for a service caller), roles (the verified role) and, for a user, cnf (kid, and jkt when the id token carried the key).
  • PrincipalMapper::contract([...]) emits only the contract names plus the product claims you name.
  • PrincipalMapper::custom(f) takes your own function.

(Since 0.17.1, cratestack#1244.) Both built-in mappers also emit amr and, for a user, auth_time, which a procedure's @authn requirement reads:

  • amr is the id token's amr claim plus pop (RFC 8176 proof of possession), since every request is signed with the bound key.
  • auth_time is the token's auth_time claim. default_id_token_claims sets it to the mint time.
  • A service caller's amr is ["pop"], with no auth_time.

IdTokenClaims and UserPrincipal gain auth_time and amr. An issuer that re-mints a token without a fresh user proof keeps the earlier auth_time.

Compatibility note ​

Exact auth keys still win before dotted traversal.

That means older flat claim maps such as organizationId can continue to work even while newer principals project nested structures such as tenant.id or organization.id.

The recommended direction for new integrations is:

  • prefer structured principals with top-level actor, session, and tenant objects when those concepts exist
  • treat ad hoc top-level scalar claims as compatibility or app-specific extensions rather than the long-term principal shape

CrateStack still accepts the older header-only resolver closure shape:

rust
Fn(&HeaderMap) -> Result<CratestackContext, CratestackError>

through a compatibility blanket implementation.

Treat that closure form as legacy compatibility only.

New code should use an explicit AuthProvider type so auth behavior stays discoverable and reusable across routers, tests, and internal service boundaries.

Released under the MIT License.