pub struct AuthorizationService<S: Storage, C: Clock> { /* private fields */ }http only.Expand description
An RFC-shaped authorization server as an HTTP service.
Built by ServiceBuilder. Cheap to clone (one refcount bump) and safe to share, so a host
clones one per connection or per task without duplicating any of the state or re-serializing
anything.
§Mounting it
With the axum feature, axum::Router::from(service) is the whole wiring. Without it, call
handle from whatever the host’s own server hands it: it takes
an http::Request over any http_body::Body and answers with an
http::Response<Body>.
Implementations§
Source§impl<S: Storage, C: Clock> AuthorizationService<S, C>
impl<S: Storage, C: Clock> AuthorizationService<S, C>
Sourcepub async fn handle<B>(&self, request: Request<B>) -> Responsewhere
B: Body,
pub async fn handle<B>(&self, request: Request<B>) -> Responsewhere
B: Body,
Answer one request.
Generic over the request body so that a host on any HTTP server can call it: hyper,
axum, a test harness holding a String. The body is read whole, up to 64 KiB, before it
is parsed, which is what these endpoints require (client
authentication for client_secret_post is IN the body, so nothing can be checked before
it has all arrived).
Trait Implementations§
Source§impl<S, C> From<AuthorizationService<S, C>> for Router
Mount the service on axum.
impl<S, C> From<AuthorizationService<S, C>> for Router
Mount the service on axum.
This is the ENTIRE axum surface of this crate, and it is one function on purpose. axum is a
0.x crate: its major has moved before and will move again, and every earlier version of this
module put axum::Router in the return type of the only way to use the http feature, which
meant a host on a different axum major could not enable the feature at all. Confining axum to
an adapter behind its own feature makes that a per-host decision instead of this crate’s.
A fallback rather than a route per endpoint: the route table is DERIVED from the metadata
document at build time, so re-declaring it here in axum’s syntax would create
a second table that could disagree with the first. A 404 from
AuthorizationService::handle is a path this server does not serve, which is exactly what a
fallback means.
§Why the request is answered on a spawned task
BECAUSE A CLIENT THAT HANGS UP MUST NOT BE ABLE TO STOP THE SERVER MID-SEQUENCE. hyper drops
the service future when the connection closes, and a dropped future does not fail: the code
after the .await it was suspended on simply never runs. crate::server has no transactions
(Storage deliberately offers none), so several of its sequences are an atomic TAKE followed
by a write, and every one of those arguments about which way the pair fails assumes that a
failure HAPPENS. The refresh rotation is the sharp case: Storage::take_refresh_token removes
the chain and the spent marker that arms RFC 9700 s4.14.2 reuse detection is written after it,
so a drop in between leaves the chain gone with no marker, which is the exact state that
ordering exists to prevent. The authorization code path has the same shape with its consumed
record. Neither is a race an attacker has to win by timing: whoever presents the credential is
whoever decides when to close the socket.
Awaiting a JoinHandle moves the cancellation to the RIGHT place. The client’s disconnect
cancels this adapter’s await on the handle; the spawned task keeps its own place in the runtime
and runs the store sequence to the end. Nothing else in this crate can do this, because the
http feature deliberately pulls in no runtime; the axum feature is the one place a runtime
is already present (axum = ["http", "dep:axum", "dep:tokio"]), so it is the one place this can
be contained. A HOST MOUNTING AuthorizationService::handle ITSELF OWNS THIS, and should
spawn for the same reason.
§What a host may notice
IN-FLIGHT WORK IS NO LONGER BOUNDED BY CONNECTIONS. That is the point of the spawn and it is also its cost: a client that hangs up stops waiting but no longer stops the work, so axum’s and hyper’s connection limits, and any accept-side bound the host set, no longer bound the tasks this service is running. The bound becomes request RATE times handler latency, and NEITHER FACTOR IS THIS CRATE’S TO SET.
The rate half is the stronger one: the limiter runs INSIDE AuthorizationService::handle and
refuses before the store is touched, so a refused request costs a spawn and nothing more. It is
still not a global ceiling — the budgets crate::rate_limit ships for the endpoints that
name a client are keyed per client_id, which RFC 6749 section 2.2 makes public, so a caller
spraying identifiers gets a budget apiece up to
crate::rate_limit::DEFAULT_MAX_TRACKED_CLIENTS counters before the rest share an overflow
counter.
The latency half is the host’s outright. A handler makes a bounded NUMBER of store calls, but
each one is the host’s crate::store::Storage and this crate sets no timeout anywhere, on
anything; the token path additionally awaits crate::jwt::Es256Signer, which that trait’s
own docs say may be a network round trip to a KMS. Nor is the latency all waiting:
a host-installed crate::client::SecretVerifier runs its KDF INLINE on the executor thread
polling the request — that trait prices argon2id at ordinary parameters at roughly 200 ms, paid
per token request and on the unknown-client path too — so it occupies a worker rather than
yielding it. A host that wants a hard
ceiling should take a semaphore permit before the spawn, or spawn into a JoinSet it owns, and
answer 503 when it cannot get one.
A PANIC in a handler no longer unwinds into hyper. It arrives here as a JoinError and is
answered with an empty 500, which is what the panicking connection produced anyway, minus the
connection dying with it. RUNTIME SHUTDOWN is the other JoinError: a task cancelled because
its runtime is going away answers the same 500. The two are not distinguished on the wire on
purpose, because they are the same news to the client (this request did not complete and it
does not know whether anything happened), and both are already visible to the host: a panic
through its own hook, a shutdown because it asked for one.
§Cost
One tokio::spawn per request, which is ONE allocation: measured with tests/support/alloc.rs
on aarch64-apple-darwin at 1 alloc and 128 bytes for a trivial task, the block sized by the task
header plus the handler future. tests/allocation.rs budgets the REQUEST path, which this does
not touch: nothing inside AuthorizationService::handle changes, and the token endpoint’s own
budget there is two orders of magnitude larger than one task. It buys the store sequence the
right to finish.
The other half of the cost is not an allocation. Detaching the handler from the connection
means in-flight work is bounded by request RATE rather than by concurrent connections: a client
that disconnects immediately after sending no longer sheds any load, because the handler it
started runs to completion regardless. That is the same property that buys the store sequence
its right to finish, seen from the load side. A host that relied on disconnects for
backpressure needs a concurrency limit in front of this service — tower::limit or the
equivalent — and the rate limiter this crate already has does not substitute for one, because
it refuses attempts rather than bounding work already accepted.