ADR 0022: Authentication Requirements in the Schema
Status
Proposed, 2026-10-31. It is the first child story of epic cratestack#1244. The server side described here is built in the framework PR for #1244 (§1–§4, §6), and the typed client value (§5) in a second PR of the same epic. Both shipped in CrateStack 0.17.1 (2026-10-31), with the end-to-end examples of cratestack#1245; acceptance is still the maintainer's decision.
The number is the next free one across both repositories, after ADR 0021 (the AuthProvider seam). This ADR builds on 0021's refusal envelope (AuthRefusal, §1 there) and principal contract (§2 there), and lives here because both the grammar and the wire signal are user-visible.
Date
- 2026-10-31: proposed, with §1–§4 and §6 built.
- 2026-10-31: §5 built (cratestack#1256), and everything here released in CrateStack 0.17.1.
Decisions for the maintainer
| # | Question | Proposal | State |
|---|---|---|---|
| D1 | How does a schema state the requirement? | A procedure attribute, @authn(maxAge: 300, amr: ["hwk"], acr: ["aal2", "aal3"]). Each key is optional, at least one is required, and each key appears at most once. | built |
| D2 | What does it mean? | Checked after @allow/@deny, against the principal contract's auth_time, amr and acr. A missing fact fails closed. | built |
| D3 | What does a refusal look like? | RFC 9470: a 401 AuthRefusal with error: "insufficient_user_authentication", a details naming what is required and what was unmet, and a Bearer challenge on REST. | built |
| D4 | Which paths enforce it? | Every path to the procedure: REST, RPC unary, each RPC batch frame, MCP tool calls and non-HTTP callers. All of them go through the procedure's one authorize step. | built |
| D5 | How do clients react? | A typed StepUpRequired { maxAge, acrValues, amr, unmet } in Rust, Dart and TypeScript (and the riverpod, swr and fespalier layers). Each also gets a helper that retries at most once after a successful re-proof. | built (second PR) |
| D6 | Who fills the facts? | The provider. cratestack-auth fills auth_time and amr (adding pop for a signed request), and COSE signers report amr: ["pop"]. Authkestra's providers map their own claims. | built |
Context
Before this ADR, a policy could say who may call (@allow(hasRole("teller"))), never how recently or how strongly they must have authenticated:
auth().<path>compares only for equality, soauth_timecannot be checked against a clock.- There was no set membership either, so "
amrcontainshwk" could not be written. - A refusal was
401or403with a fixed code, so a client could not tell "re-authenticate" from "never allowed". - fespalier's
examples/cratestackhand-codes "this call needs the user-present key" with a header and a server-side check (payOrder). - Authkestra ships
max_agere-authentication andacr/amr(its RFC-006 and RFC-007), but a CrateStack server had nowhere to declare them.
ADR 0021 gave every provider the reserved claims (auth_time in seconds, amr a list of strings, acr a string) and gave every transport a refusal a provider can shape. This ADR puts a requirement on top of both.
Goals. One declaration per operation, enforced by generated code on every transport, refused with a standard signal, read by every generated client.
Non-goals. Running the re-authentication ceremony, which is the auth library's or the app's job. Embedded schemas, which have no policy enforcement by design. A model-level @@authn (Q1).
Decision
1. The attribute (D1)
mutation procedure payOrder(args: PayArgs): Receipt
@allow(auth() != null)
@authn(maxAge: 300, amr: ["hwk", "user"], acr: ["aal2", "aal3"])| Key | Value | Satisfied when |
|---|---|---|
maxAge | whole seconds, 1 to 31 536 000 (one year) | now - auth_time <= maxAge. An auth_time in the future reads as fresh, because skew is the provider's concern. |
amr | a non-empty list of quoted strings (RFC 8176 values) | every listed method is in the caller's amr |
acr | a non-empty list of quoted strings | the caller's acr is one of them |
The rules for writing it:
- A value is letters, digits and
._:/~+-, so a URN such asurn:mace:incommon:iap:silveris allowed. A value may not repeat within its list. - Keys are camelCase, like
@mcp's. - Lists use brackets, like an enum's values.
- The attribute is raw text in the IR, like
@isolation, read by one function:cratestack_core::schema::parse_authn_attribute. - The parser refuses anything that function does not read: an unknown key, a repeat, a non-list, an empty list, a second
@authn, a misspelling (with a suggestion) or a space before(. - The generator calls the same function, so a requirement it cannot read cannot compile either.
@authn is on the op contract's drop list: it decides who may call, not how bytes decode, so adding one moves no client digest.
2. Semantics (D2)
The generated authorize step runs, in order:
- argument validators;
@allow/@deny;@authn;- any
@authorizemodel check.
Putting @authn after the policy is deliberate. A caller the policy refuses gets 403: asking them to re-authenticate cannot help. An anonymous caller a permissive @allow admits has no auth_time, so a requirement refuses them with a step-up.
The facts are read through the principal contract's typed readers (auth_time(), amr(), acr()). A missing or mistyped claim reads as absent, so the requirement fails closed.
Server time is the clock. maxAge is checked against SystemTime::now(), and cratestack::require_authn_at takes an explicit time for tests and custom callers.
3. The wire signal (D3)
An unmet requirement is an AuthRefusal (ADR 0021 §1), so every transport renders it the way it renders any provider refusal:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication", error_description="step-up authentication required", max_age="300", acr_values="aal2 aal3"
{
"code": "UNAUTHORIZED",
"message": "step-up authentication required",
"error": "insufficient_user_authentication",
"details": {
"max_age": 300,
"acr_values": ["aal2", "aal3"],
"amr": ["hwk", "user"],
"unmet": ["max_age", "amr"]
}
}detailsuses RFC 9470's snake_case names.max_age,acr_valuesandamrare present only when required.unmetlists which ofmax_age,acrandamrfailed, in that order.- The challenge's
error_descriptionis the message.max_ageandacr_values(space-separated) appear only when required.amrhas no RFC 9470 challenge parameter, so it travels only indetails. - The challenge scheme is
Bearer, as RFC 9470 writes it, whatever the provider's own scheme is. Q3 asks whether that should change. - A client classifies a remote error as a step-up when its status is 401 (RPC:
unauthenticated) and itserrormember isinsufficient_user_authentication. A plain 401 is not a step-up.
4. Transports (D4)
Every transport reaches a procedure through the generated authorize_with_db / invoke_with_db (or the db-less authorize / invoke), and all four call require_authn, so no transport can skip it.
| Path | Refusal |
|---|---|
| REST | 401, envelope and challenge as in §3 |
| RPC unary | 401, code: "unauthenticated", the same details and error member. The challenge rides along: RPC dispatch re-enters the REST handler, and its re-encoding keeps the handler's WWW-Authenticate (cratestack-axum/src/rpc/error_encode.rs) |
| RPC batch | per frame: the frame that needs a step-up carries the error, and its neighbours run. The envelope authenticated once, so auth_time is the envelope's. |
| MCP tool | an isError result whose text is the REST envelope, and the tool never runs. ADR 0002 D5 exempts MCP from transport parity, but the requirement lives in the authorize step MCP already calls, so an @mcp(tool) procedure cannot skip it. |
| Non-HTTP callers | the CratestackError::AuthRefused the authorize step returns |
5. Clients (D5)
Each generated client gains:
- a
StepUpRequiredvalue (max_age/maxAge,acr_values/acrValues,amr,unmet), read from a refusal by the rule in §3 and tolerant of missing or mistyped fields; - a helper that retries a call at most once after a successful re-proof:
- Rust:
retry_after_step_up; - Dart and TypeScript:
retryAfterStepUp.
- Rust:
The riverpod, swr and fespalier layers surface the typed value unchanged. fespalier maps it onto its session, so the app re-proves (a user-present key, a passkey) and retries; that mapping is tracked in fespalier's epic. This half of the epic was a separate PR, built in cratestack#1256.
A retry under the same Idempotency-Key meets the recorded 401 on 0.17.1: the idempotency layer records a refusal like any other answer and replays it. (Unreleased, cratestack#1261.) A refusal produced before any procedure body runs (the provider's, @allow / @deny's, @authn's) is marked as the dispatch's own and released instead, as an @isolation procedure's own TRANSACTION_ABORTED is; one a body returns is still recorded.
6. Who fills the facts (D6)
| Provider | auth_time | amr | acr |
|---|---|---|---|
cratestack-auth, a user with an id token | the token's auth_time claim, set to the mint time by default_id_token_claims | the token's amr, plus pop (RFC 8176 proof of possession: the request is signed with the bound key) | none yet |
cratestack-auth, a service key | none | ["pop"] | none |
cratestack-cose, a verified signer | none | ["pop"] | none |
Authkestra OIDC (examples/auth-pluggable) | the token identity's auth_time attribute | its amr | its acr |
An issuer that re-mints a token without a fresh user proof must keep the earlier auth_time. cratestack-auth's claim doc says so.
Consequences
Benefits.
- A step-up rule is one line in the schema, enforced everywhere the procedure is reachable.
- The signal is the standard one, so OAuth and OIDC clients that already speak RFC 9470 understand it.
- Any provider plugged in through ADR 0021's seam satisfies a requirement by filling three reserved claims.
Costs.
- One more attribute in the grammar, and one more step in every authorize call. With no
@authnthe step is aNonecheck. - A provider that does not know
auth_timefails everymaxAgeclosed. That is intended, anddetails.unmetnames the part.
Alternatives considered
| Alternative | Why not |
|---|---|
A policy expression, @allow(auth().auth_time > now() - 300) | Needs ordering, a clock and set membership in the policy language. It would make a step-up indistinguishable from a 403, and the refusal could not say what to re-prove. |
Check before @allow | Asks a caller to re-authenticate for an operation they may never call. |
| A per-transport check | Exactly the parity gap ADR 0021 closed. The authorize step is the one path every transport takes. |
A 403 with insufficient_scope | RFC 9470 defines insufficient_user_authentication as a 401 so a client knows to re-authenticate; 403 means re-authenticating will not help. |
Open questions for the maintainer
| # | Question | Proposal |
|---|---|---|
| Q1 | A model-level @@authn("update,delete", maxAge: 300)? Model writes are enforced in the runtime ORM, with no single generated choke point per operation. | Later: a dispatch-level pre-check covers REST and RPC but not direct ORM callers; decide whether that split is acceptable first |
| Q2 | A keyRole: "userPresent" key, reading ADR 0021's key_role? | Yes, next. fespalier's payOrder needs exactly "signed by the user-present key", which amr: ["user"] approximates only when the provider fills it |
| Q3 | Should the challenge scheme follow the provider (Signature, DPoP) rather than always Bearer? | Keep Bearer (RFC 9470's) until a client needs otherwise; details carries everything regardless |
| Q4 | Should an acr list express an order ("aal2 or stronger")? | No. List every acceptable class: the schema cannot know a provider's ordering |
| Q5 | Should cratestack diff report adding or tightening an @authn? | Yes, as a Warning: existing clients start receiving 401s. diff does not compare procedure attributes today |
| Q6 | Should auth_time far in the future (clock skew) be refused? | No, the provider owns skew; revisit if a provider is found to stamp future times |
Implementation plan
- This ADR.
- Parser, LSP and the op contract: built.
- Enforcement on REST and RPC unary: built.
- Enforcement per RPC batch frame, and on MCP tools: built.
- Rust client
StepUpRequired: built. - Dart client, with the riverpod and fespalier layers: built.
- TypeScript client, with swr: built.
- In-house providers fill
auth_timeandamr: built.
Items 5–7 are the clients PR (cratestack#1256). Every item shipped in 0.17.1.