Skip to main content

Module par

Module par 

Source
Expand description

Pushed authorization requests (RFC 9126), and where a sign-in is decided.

One seam, and this is it. PAR is the earliest point the client is known — the first request that both names a client_id and can be validated against fetched metadata — and it is where the decision record an agent later answers is written. Two inputs reach that verdict and nothing else does: plan/policy.md’s deny-only tree, asked about a didbot_policy::Subject::Grant, and plan/scope-policy.md’s ceiling, applied by intersection.

Client admission is one of those denials rather than a seam beside them. plan/app-allowlist.md’s “refuse at the pushed authorization request” is a denyClient-shaped policy in the tree, and it is asked about before anything else here: didbot_policy::Subject::Grant names a client_id and the requested scope but no account, so the gate’s answer cannot depend on login_hint and does not wait for it. A client the gate refuses outright gets no decision record and no request_uri — see ParError::ClientRefused — the opposite of every other denial this module writes, and deliberately so: the record would have to say so to be useful, and saying so to an unauthenticated caller is exactly what plan/app-allowlist.md keeps the operator’s to know.

The record is never judged again. GET /oauth/authorize and bot.did.approveAuthorization read the record this module wrote — see crate::oauth::decision — so a policy edited after a push never changes an answer already given. It does bind the token that answer would lead to: POST /oauth/token asks the gate again before issuing one — see crate::oauth::token::GateAdmission.

login_hint naming nobody is not a third seam either — it is not a seam at all. This endpoint is unauthenticated, and no observable answer may depend on whether an account exists: an account this deployment hosts, an account plan/policy.md’s gate would narrow or deny, an account whose own pending bound is already full, and a login_hint matching nobody here all get the same 201, or pushing one request per candidate handle — or enough variously-spelled requests for one candidate — is a directory of this deployment’s accounts. The one 403 this endpoint answers with is the server’s own pending_total bound, which is identity-independent by construction: true for every caller alike, so answering it truthfully leaks nothing. See push_for_unresolved_hint (private, this module) for what a nonexistent hint gets instead of a decision record, and why an account-keyed bound on it — an earlier version of this fix — reopened the hole by a different door.

An account at its own bound displaces its oldest undecided record. login_hint is whatever the caller writes, so an account’s eight slots are fillable by a stranger. Dropping the newest push let that stranger keep an account from signing in to anything; displacing the oldest keeps the bound and keeps the account’s freshest attempt answerable — including when this server’s own pending_total is full, since a push that gives a slot back before it takes one does not grow the store. See crate::oauth::decision::DecisionStore::put.

Structs§

DecisionSeams
The seams PAR reaches to work out what it would grant, bundled so push_authorization_request takes one argument rather than seven.
DroppedPushes
How many pushes have not left an answerable decision record behind, this process’s lifetime, by reason.
MemoryParStore
The in-process PAR store.
ParRequest
The raw wire fields POST /oauth/par accepts, before client metadata is resolved.
PushedRequest
One pushed authorization request, as pushed.

Enums§

ParError
Why a pushed request could not be accepted.

Constants§

CEILING_VERSION 🔒
What a ceiling’s row names where a gate’s row names the policy version it judged under.
DISPLACED_PUSH_LOG_SAMPLE 🔒
How often log_displaced_push actually emits a log line, rather than only counting one.
MAX_LOGIN_HINT_BYTES
The longest login_hint a push may carry, in bytes.
MAX_STATE_BYTES
The longest state a push may carry, in bytes.
REQUEST_URI_BYTES 🔒
Random bytes a request_uri is drawn from, before encoding.
REQUEST_URI_PREFIX 🔒
The prefix RFC 9126 requires: “the URI … MUST include the urn:ietf:params:oauth:request_uri prefix”.
REQUEST_URI_TTL
How long a pushed request stays redeemable. RFC 9126 recommends 60 seconds; this server gives a little more room for the agent-driven browser plan/oauth.md describes to load authorize before the request URI it just pushed goes stale.
S256_CHALLENGE_LENGTH 🔒
How long an S256 code_challenge is: the unpadded base64url encoding of a SHA-256 digest (RFC 7636 §4.2).

Statics§

DISPLACED_PUSH_COUNT 🔒
How many undecided records have been displaced past an account’s own pending bound, this process’s lifetime; see DISPLACED_PUSH_LOG_SAMPLE.
SERVER_FULL_COUNT 🔒
How many pushes this server’s own pending_total refused; see ParError::TooManyPending.
UNRESOLVED_HINT_COUNT 🔒
How many pushes named a login_hint this deployment resolved to no account; see push_for_unresolved_hint.

Traits§

ParStore
Where pushed requests live between POST /oauth/par and authorize.

Functions§

at_most 🔒
Refuses value if it is longer than max bytes.
decide 🔒
The verdict for one request: what the ceiling narrows or denies. The gate is not asked here — push_authorization_request already asked it, once, before this account was even known to exist, because didbot_policy::Subject::Grant has no account for this function’s did to change the answer of. A gate’s own refusal is ParError::ClientRefused, not a Verdict this function could return; only the ceiling’s per-account narrowing lives here.
dropped_pushes
DroppedPushes as it stands now.
is_s256_challenge 🔒
Whether challenge has the shape of an S256 code challenge: exactly S256_CHALLENGE_LENGTH characters of the base64url alphabet.
log_displaced_push 🔒
Logs a displaced record at info, sampled to one in DISPLACED_PUSH_LOG_SAMPLE so the log line itself cannot be flooded.
origin_of 🔒
The scheme, host and port of a client_id.
push_authorization_request
Handles one pushed authorization request end to end: resolves and validates the client’s metadata, checks the request against it, resolves the account it names, decides what it would grant, and stores both the request and the decision.
push_for_unresolved_hint 🔒
What login_hint naming nobody gets instead of push_authorization_request’s ordinary decision record: the identical success this endpoint hands back for a real account, and nothing an unauthenticated caller — or this store — can act on.
random_request_uri 🔒