Routes: signed in by default
On this page
Every route needs a signed-in caller unless it says otherwise. You don't write anything for that. A request without a valid session or token is refused with a 401 before your code runs.
A route changes this with an authentication: line, which takes one of two
values.
Open to anyone: RouteAuthentication.none
Use it for routes people call before they have an account, such as a health check or a sign-up form.
GET /health/live { authentication: RouteAuthentication.none output: Health run: live()}
The route doesn't look for a session or token, so it never knows who is
calling. The compiler stops anything the route runs from reading the caller
(type.auth_public_principal).
Only the sign-in check is skipped. Input is still validated and limits still count. Policy still decides whether the route's action may run for someone who isn't signed in. A route can be open to anyone and still be refused by policy.
Check the caller again: RouteAuthentication.fresh
Use it where acting for someone who was signed out a moment ago would do harm, such as disabling an account, changing permissions, billing or exports.
DELETE /users/{user_id} { authentication: RouteAuthentication.fresh run: disable_user(user_id, current_principal.user)}
fresh only changes anything if your app sets a revocation_delay. By
default, Jadpo looks up the caller's record on every request, so a session
you revoke stops working at once. With a delay, for example
revocation_delay: 5m, sessions are trusted for up to five minutes without
that lookup, so a disabled user may keep working until then (see
The application and revocation). A
fresh route always does the lookup, whatever the delay.
Any other value
Any other authentication: value is a compile error. The error offers both
fixes rather than guessing, because one keeps the route protected and the
other opens it to anyone.
Confirm one request with a passkey: confirm: Confirmation.passkey
Use it where a stolen session or token must not be enough, such as releasing a package, moving money or deleting an account. The signed-in person approves this one request with one of their own passkeys.
use payouts/entities { Payout }use payouts/actions { send_payout }routes Payouts { prefix: /payouts POST /{payout_id: Payout.id}/send { confirm: Confirmation.passkey status: SuccessStatus.no_content run: send_payout(payout_id) }}
A client confirms a request in three steps, around the browser's own
prompt. It first sends the same request to the route's generated
<path>/confirmation, with the same method, placeholders, query or body
and headers (a GET's goes as a POST, since asking keeps a challenge),
for the options to give navigator.credentials.get()
(PublicKeyCredential.parseRequestOptionsFromJSON reads what publicKey
holds):
POST /payouts/0192d3c4-.../send/confirmation200 OK{ "publicKey": { "challenge": "Vh3q...", "rpId": "payouts.example", "allowCredentials": [{ "type": "public-key", "id": "AQID..." }], "userVerification": "required", "timeout": 120000 } }
The browser has the person present a passkey, and the request itself
carries the device's answer in X-Jadpo-Confirmation, the JSON WebAuthn's
toJSON() writes, in base64 (btoa(JSON.stringify(credential))):
POST /payouts/0192d3c4-.../sendX-Jadpo-Confirmation: eyJpZCI6IkFRSUQi...204 No Content
- The challenge is bound to the request. The runtime reads the
options request as the route itself would, so input the route would
refuse is refused here, before anyone is asked for a passkey. It binds
the challenge to the signed-in person and a digest of the route and
that input, the one an
Idempotency-Keyis checked against. So the same input written differently, with its members in another order, is the same request. - The options list only the person's passkeys. A person with none is
refused with 403
confirmation_requiredrather than given options any passkey could answer, so a frontend sends them to add one. - It is checked before anything runs: the passkey must be one the
signed-in person registered, the answer must show the person was
verified (a fingerprint, a face or a PIN), and the challenge must be
unused, under two minutes old and match this request's input. Anything
else is refused with 403
confirmation_required, and nothing runs. The answer is checked as a passkey's sign-in is (Signing in): the origin, the relying party, the signature and the counter. On success the passkey's counter, backup state and last use are written. - Each challenge works once, so an answer can't be replayed, and one made for one request can't approve another. It is used up when the answer arrives, whether or not the answer checks. A new options request for the same person, route and input replaces the earlier one.
- The two minutes are fixed.
passkey:'sexpires_after:doesn't change them. - A retry confirms again. An
Idempotency-Keyis checked after the confirmation, so a client repeating a request with its key presents a passkey again and gets the first response. A confirmation can't be skipped by repeating a key. - It needs a person signed in.
confirm:on a route withauthentication: RouteAuthentication.none, or one that passes oncurrent_principal.service, isroute.confirm_invalid. Any credential of a signed-in person can carry an answer, since the answer is what proves it's them; a route that also needs their record read now addsauthentication: RouteAuthentication.fresh. - It needs
passkey:insign_in:(semantic.auth_setting_required), and a route that needs it says so in its API's record (confirm passkey) and OpenAPI (x-jadpo-confirm, with the header and the 403). The generated route is listed beside it in both and injadpo routes, marked generated. A route your app writes at that path isroute.path_overlap, as is a confirmedGETbeside a confirmedPOST, which would share one. - Taking it off needs a person. A route that stops asking for a
confirmation needs an acceptance,
PolicyChange.confirmation_removed(Records), since a stolen session is then enough again. - In route tests,
test_passkeys.confirm(passkey)answers the next confirmation with that test passkey (Passkeys in tests).
A route test confirms the next request with a test passkey. Without it, or with another person's, nothing runs:
use payouts/entities { Payout }use payouts/fixtures { drafted_by, treasurer_called }test "a payout goes out once its treasurer confirms it with a passkey" { var ada: Principal.user = treasurer_called("ada@example.com") var phone: TestPasskey = test_passkeys.register(ada, "Phone") var payout: Payout = drafted_by(ada) test_passkeys.confirm(phone) var response: Response = request { method: HttpMethod.post path: "/payouts/{payout_id}/send" params: { payout_id: payout.id } as: ada } assert response.status == 204}test "a stolen session alone sends nothing" { var ada: Principal.user = treasurer_called("ada@example.com") var phone: TestPasskey = test_passkeys.register(ada, "Phone") var payout: Payout = drafted_by(ada) var response: Response = request { method: HttpMethod.post path: "/payouts/{payout_id}/send" params: { payout_id: payout.id } as: ada } assert response.status == 403 assert response.code == "confirmation_required" var signed_in: Response = test_passkeys.sign_in(phone) assert signed_in.status == 204}test "another person's passkey confirms nothing" { var ada: Principal.user = treasurer_called("ada@example.com") var mallory: Principal.user = treasurer_called("mallory@example.com") var ada_phone: TestPasskey = test_passkeys.register(ada, "Phone") var mallory_phone: TestPasskey = test_passkeys.register(mallory, "Phone") var payout: Payout = drafted_by(ada) test_passkeys.confirm(mallory_phone) var response: Response = request { method: HttpMethod.post path: "/payouts/{payout_id}/send" params: { payout_id: payout.id } as: ada } assert response.status == 403 assert response.code == "confirmation_required" var signed_in: Response = test_passkeys.sign_in(ada_phone) assert signed_in.status == 204}
The API record says the route asks for a passkey, and lists the route Jadpo answers beside it:
POST /payouts/{payout_id: Uuid}/send confirm passkey status 204 fails payout_not_foundPOST /payouts/{payout_id: Uuid}/send/confirmation generated confirmation caller user output PasskeyRequestOptions status 200
Apps with more than one API
An app can serve several APIs, for example main for its own clients and
partners for other companies' servers, each under its own path prefix
(APIs). Each API lists the ways a caller may prove who they
are, and every route in it accepts those:
// app.jadpoapis: { main: { prefix: / authentication: [AuthenticationMethod.browser_session, AuthenticationMethod.api_token] } partners: { prefix: /partners authentication: [AuthenticationMethod.service_key] }}
Here a browser session or an API token works on any route under /, and
only a service key works under /partners. A route can narrow its API's
list but never add to it. An API with no authentication: line accepts every
method the application declares, and one that says authentication: []
accepts none: every route in it is open to anyone, and says so
(Serving the document).
Webhooks from a provider such as Stripe aren't routes you write, so there's nothing here to set. Jadpo generates them, checks each one's signature and passes it to your app as an event (External services).
Markdown for agents: /docs/routes-signed-in-by-default.md.Built from Jadpo main at aee2e8f.