API tokens
At the door, scanners read the ticket's code from the fan's phone. Screenshots of tickets get resold, so the box office must let each ticket in only once, no matter which door it's scanned at. The door scanner is a second, small Margaret application. It asks the box office to check every ticket in, with an access token that proves it's a real scanner.
The box office becomes an OAuth authorization server: it issues access tokens to the clients it admits, for the resources it protects.
The resource
Declare what the tokens are for. Each resource has an audience, which access tokens for it carry:
use margaret::framework::macros::issues_resource_tokens;
#[issues_resource_tokens(check_ins, audience = "https://tickets.example.com/check-ins")]
pub struct CheckInsResource;
Admitting the scanner
Then admit the door scanner as a client. It proves who it is with private_key_jwt: it signs a short assertion with its own private key, and the box office checks it against the keys the scanner publishes. No shared secret ever leaves the scanner. It may get tokens for itself, with the client credentials grant, for the check_ins resource only:
use margaret::framework::accepted_clients::client_keys::ClientKeys;
use margaret::framework::jose_parameters::jws_algorithm::JwsAlgorithm;
use margaret::framework::macros::admits_oauth_client;
use margaret::framework::oauth_vocabulary::client_authentication_method::ClientAuthenticationMethod;
#[admits_oauth_client(
door_scanner,
authentication = ClientAuthenticationMethod::PrivateKeyJwt(
client_credentials(scopes = []),
keys = ClientKeys::Published(
jwks_uri = "https://doors.example.com/.well-known/jwks.json",
signing = JwsAlgorithm::Es256,
),
),
client_id = "door-scanner",
resources = [check_ins],
)]
pub struct DoorScannerClient;
Each assertion can be used once. Margaret remembers the ones it has seen, in a new framework table that schema now includes.
Provider endpoints
An authorization server publishes three endpoints: its discovery document, its public keys, and the token endpoint. The framework serves all of them, so they're just declarations, like the sign-out route:
use margaret::framework::macros::responds_to_http;
use margaret::framework::macros::serves_oidc_endpoint;
use margaret::framework::oidc_provider::oidc_endpoint::OidcEndpoint;
use margaret::framework::route_method::route_method::RouteMethod;
#[responds_to_http(
method = RouteMethod::Get,
path = "/.well-known/openid-configuration",
server = "public"
)]
#[serves_oidc_endpoint(OidcEndpoint::Discovery)]
pub struct GetDiscovery;
use margaret::framework::macros::responds_to_http;
use margaret::framework::macros::serves_oidc_endpoint;
use margaret::framework::oidc_provider::oidc_endpoint::OidcEndpoint;
use margaret::framework::route_method::route_method::RouteMethod;
#[responds_to_http(
max_body_bytes = 16_384,
method = RouteMethod::Post,
path = "/token",
server = "public"
)]
#[serves_oidc_endpoint(OidcEndpoint::Token)]
pub struct IssueToken;
The keys live at GET /.well-known/jwks.json with OidcEndpoint::Jwks. The discovery document must sit where the issuer says it is, so the public server now runs at the issuer's address, https://tickets.example.com. That's why serve no longer asks for --public-url.
Who is calling
A route asks for the caller the same way it asks for a customer: through an authenticated user. The Gate is a plain struct, and its provider reads the access token with #[bearer_token]:
#[singleton]
#[infers_authenticated_user(user_model = Gate)]
pub struct GateProvider;
impl GateProvider {
#[infer_from_request]
pub fn infer(
&self,
#[bearer_token(resource = check_ins)] token: Option<
VerifiedJwt<GateClaims, AccessTokenProfile>,
>,
) -> anyhow::Result<AuthenticatedUserOutcome<Gate>> {
Ok(match token {
Some(verified) => AuthenticatedUserOutcome::Authenticated(Gate {
client_id: verified.claims.client_id,
}),
None => AuthenticatedUserOutcome::Anonymous,
})
}
}
#[bearer_token(resource = check_ins)] accepts only access tokens the box office issued for that resource, unexpired, with a valid signature. GateClaims is your own struct with the claims you need, here the client_id. A request without a valid token gets a 401 with a WWW-Authenticate: Bearer header.
Letting each ticket in once
A check-in belongs to exactly one ticket, so its primary key is the ticket's key:
#[model(table = "check_ins")]
pub struct CheckIn {
#[column(primary_key)]
#[foreign_key(on_delete = OnDelete::Cascade)]
pub ticket: Key<Ticket>,
#[column]
pub gate: String,
#[column]
pub checked_in_at: DateTime<Utc>,
}
The route binds the ticket together with its order, refuses unpaid tickets, and records the check-in. insert().or_ignore() answers AlreadyPresent when the ticket was already used, even when two doors scan it at the very same moment:
#[process]
pub async fn respond(
&self,
#[authenticated_user] gate: Gate,
#[route_parameter(from = "ticket")] TicketWithOrder { ticket, order }: TicketWithOrder,
) -> anyhow::Result<Response> {
if order.status != OrderStatus::Paid {
return Ok(Response::json(
409,
&json!({ "error": "This ticket isn't paid." }),
));
}
let check_in = CheckIn {
ticket: Key::of(&ticket),
gate: gate.client_id,
checked_in_at: self.clock.now(),
};
Ok(
match check_in.insert().or_ignore(self.database.as_ref()).await? {
Insertion::Inserted => Response::json(201, &json!({ "seat": ticket.seat })),
Insertion::AlreadyPresent => Response::json(
409,
&json!({ "error": "This ticket has already been used." }),
),
},
)
}
TicketWithOrder is a shape: a ticket with its order.
The door scanner
The scanner is its own crate, next to the box office:
[dependencies]
anyhow = "1.0"
async-trait = "0.1"
clap = "4.5"
margaret = "0.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tokio-util = "0.7"
trzcina = "0.4"
uuid = { version = "1.18", features = ["serde"] }
validator = { version = "0.20", features = ["derive"] }
It trusts the box office as an issuer, discovered from its address, and declares itself the box office's client:
use margaret::framework::macros::verifies_tokens_from_issuer;
use margaret::framework::trusted_issuer::issuer_keys::IssuerKeys;
#[verifies_tokens_from_issuer(
box_office,
audience = "door-scanner",
issuer = "https://tickets.example.com",
keys = IssuerKeys::Discovered
)]
pub struct BoxOffice;
use margaret::framework::macros::oauth_client;
use margaret::framework::oauth_vocabulary::client_authentication_method::ClientAuthenticationMethod;
#[oauth_client(
box_office_client,
authentication = ClientAuthenticationMethod::PrivateKeyJwt,
client_id = "door-scanner",
issuer = box_office,
)]
pub struct BoxOfficeClient;
Its signing keys need a home, so the scanner has a small database of its own, declared with #[postgres_database], and publishes the public half of its keys:
use margaret::framework::macros::responds_to_http;
use margaret::framework::macros::serves_oidc_endpoint;
use margaret::framework::oidc_provider::oidc_endpoint::OidcEndpoint;
use margaret::framework::route_method::route_method::RouteMethod;
#[responds_to_http(method = RouteMethod::Get, path = "/.well-known/jwks.json", server = "public")]
#[serves_oidc_endpoint(OidcEndpoint::Jwks)]
pub struct GetJwks;
Getting tokens
Margaret generates a ClientCredentials for the client, in crate::margaret::oauth_clients::box_office_client. It fetches access tokens, and keeps each one until shortly before it expires, so the scanner doesn't ask for a new token at every scan:
pub async fn check_in(&self, ticket: Uuid) -> anyhow::Result<Response> {
let target = TokenTarget {
audience: TargetAudience::Audience("https://tickets.example.com/check-ins".to_string()),
scopes: BTreeSet::new(),
};
let token = match self.credentials.access_token(&target).await {
AcquiredToken::Acquired(token) => token,
AcquiredToken::Refused(refusal) => {
anyhow::bail!("the box office refused a token: {refusal}")
}
AcquiredToken::Unavailable(error) => {
anyhow::bail!("the box office is unavailable: {error}")
}
};
Ok(self
.http
.post(
self.box_office_url
.join(&format!("tickets/{ticket}/check-in"))?,
)
.bearer_auth(token.secret())
.send()
.await?)
}
The scanning devices post to the scanner's own gate server, and get an answer the staff can read at a glance:
#[process]
pub async fn respond(
&self,
#[form_request(from = RequestInput::Json)] scanned: ScannedTicket,
) -> anyhow::Result<Response> {
let checked_in = self.box_office.check_in(scanned.ticket).await?;
let status = checked_in.status().as_u16();
let body: Value = checked_in.json().await?;
Ok(match status {
201 => Response::text(200, format!("Seat {}. Enjoy the show!", body["seat"])),
_ => Response::text(403, format!("Stop: {}", body["error"])),
})
}
$ door_scanner serve \
--public-addr 0.0.0.0:8080 --public-url https://doors.example.com \
--gate-addr 10.0.1.5:8443 --gate-url https://gate.doors.internal \
--box-office-url https://tickets.example.com/
The scanner's schema command prints only the framework's table for its signing keys.