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:
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:
methodpathqueryheadersbody
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:
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:
{ "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:
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 theCratestackErrorthe provider returns. There is noFromconversion, so?and.into()do not apply.AuthRefusal::unauthenticated(message)is a401andAuthRefusal::forbidden(message)a403. The code staysUNAUTHORIZED/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 tocodeandmessage. The namescode,messageanddetailsare ignored: a refusal cannot replace them.with_details(value)setsdetails.with_challenge(value)adds aWWW-Authenticateheader (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/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).
| Client | REST error | RPC error |
|---|---|---|
| Rust | ClientError::Remote: remote_error() (the CratestackErrorResponse: code, message, details, extra) and challenges() | RpcClientError::Remote(RpcRemoteError): body (RpcErrorBody: code, message, details, extra) and challenges; RpcClientError::challenges() |
| Dart | CratestackRestException: code, message, details, extra, challenges, headers (and the raw body) | CratestackRpcException: code, message, details, extra, challenges, headers; RpcErrorBody.extra |
| TypeScript | CratestackHttpError: body (CratestackErrorResponse), code, details, extra, challenges (and the raw payload) | CratestackRpcError: code, details, body, extra, headers, challenges |
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:
{
"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.
| Client | The refusal | Retry 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 three | retry_after_step_up(call, reprove) |
| Dart (every preset) | StepUpRequired (maxAge, acrValues, amr, unmet); stepUp on CratestackRestException and CratestackRpcException, null when not a step-up | retryAfterStepUp(call, reprove) |
| TypeScript | StepUpRequired (maxAge, acrValues, amr, unmet); stepUp on CratestackHttpError and CratestackRpcError; stepUpOf(error), which in the RPC runtime also reads a batch frame's error body | retryAfterStepUp(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.
final receipt = await retryAfterStepUp(
() => api.procedures.transfer(args),
(stepUp) => session.reauthenticate(maxAge: stepUp.maxAge),
);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:
| Transport | method | path | query | body |
|---|---|---|---|---|
| REST procedure | POST | the declared route, e.g. /$procs/whoAmI | None | the wire bytes |
| REST model | the route's method | the declared route, with the decoded /<id> | the raw query | the wire bytes, or empty |
| RPC unary | POST | /rpc/<op_id> | None | the raw frame bytes |
| RPC batch | POST | /rpc/batch | None | the whole batch body, authenticated once |
| SSE subscription | GET | /rpc/subscribe/<op_id> | None | empty |
| MCP Streamable HTTP | POST | the path of the resource identifier | the raw query | the wire bytes |
| sync | the route's method | /sync/register, /sync/push, /sync/pull or /sync/poke | the raw query | the 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:
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::Unknownis 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.redactis 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 anAuthorization: Bearertoken as MCP does, andCredential::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:
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:
- If its
credentialisAbsent, it is skipped without being called. - If it returns an authenticated context, that is the answer.
- If it returns an error, that is the answer. A credential one member refuses is never rescued by another: the chain fails closed.
- 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:
[dev-dependencies]
cratestack-auth-conformance = "0.17"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,
detailsand challenges (on MCP, the guard's ownBearerchallenge 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
credentialsaysAbsent.
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 with401 {"error": "<code>"};authkestra-oidc: Authkestra'sJwtStrategywith DPoP-bound access tokens, mapped onto the principal contract; its tests startauthkestra-opin-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:
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:
#[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-formclaims - a legacy auth projection so existing
auth().fieldpolicies keep working
Schema expressions can continue to use the familiar auth() shape:
@@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:
#[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, andBooleanmodel 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.
| Name | Type | Meaning |
|---|---|---|
sub | string | The subject: what the framework itself uses as "who" |
roles | list of strings | The caller's roles |
role | string | Legacy single role, still read |
tenant.id | string | The tenant, in the tenant facet |
auth_time | integer, seconds since the epoch | When the user last actively authenticated |
amr | list of strings | Authentication methods (RFC 8176: hwk, user, pwd, otp, …) |
acr | string | Authentication context class |
cnf | map | Proof-of-possession confirmation (RFC 7800 members). cnf.jkt is the JWK SHA-256 thumbprint, base64url |
key_role | ambient or user_present | Which device key proved this request |
exp | integer, seconds since the epoch | When 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:
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()fillsauthandprincipalexactly asCratestackContext::from_principaldoes for the same claims, soauth().<path>in a policy and every existing reader see the result..cnf(member, value)sets any othercnfmember..tenant(..)and thecnfsetters 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:
| Reader | Returns |
|---|---|
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:
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:
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, orsvc:<key id>for a service caller),roles(the verifiedrole) and, for a user,cnf(kid, andjktwhen 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:
amris the id token'samrclaim pluspop(RFC 8176 proof of possession), since every request is signed with the bound key.auth_timeis the token'sauth_timeclaim.default_id_token_claimssets it to the mint time.- A service caller's
amris["pop"], with noauth_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, andtenantobjects 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:
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.