The merch shop (optional)
This chapter is optional. It starts from the box office as it is after Staff sign-in, and adds a third application: a merch shop at merch.tickets.example.com, selling tour shirts.
The shop wants two things from the box office. Fans who are signed in at the box office should be signed in at the shop too, without signing in again. And the back office should order shirts from the shop, through an API only it may call.
Sharing sessions
So far, session cookies are host-only: the browser sends them back to tickets.example.com and nowhere else. To share them with every subdomain, the box office switches to SharedWithDomain, and reads the domain from an environment variable:
#[issues_sessions(
issuer = box_office,
audience = "https://tickets.example.com/sessions",
cookies = SessionCookies::SharedWithDomain(domain_from = "SESSION_COOKIE_DOMAIN")
)]
pub struct FanSessions;
With SESSION_COOKIE_DOMAIN=tickets.example.com, the shop receives the same two cookies. Their names change from __Host- to __Secure-, so fans sign in once more after the switch.
The shop can verify the short-lived access cookie itself. When it expires, someone has to check the long-lived secret, and only the box office can. It offers a refresh endpoint for that, on its internal server, where callers must prove their identity with SPIFFE:
#[responds_to_http(
max_body_bytes = 4_096,
method = RouteMethod::Post,
path = "/sessions/refresh",
server = "internal"
)]
#[serves_session_endpoint(SessionEndpoint::Refresh)]
pub struct RefreshSession;
The merch shop
The shop is a Margaret application with no database at all. It trusts the box office's session tokens, discovered from its address:
#[verifies_tokens_from_issuer(
box_office,
audience = "https://tickets.example.com/sessions",
issuer = "https://tickets.example.com",
keys = IssuerKeys::Discovered
)]
pub struct BoxOffice;
And instead of issuing sessions, it consumes them:
#[consumes_sessions(
issuer = box_office,
cookie_domain_from = "BOX_OFFICE_COOKIE_DOMAIN",
refresh_url_from = "BOX_OFFICE_SESSION_REFRESH_URL"
)]
pub struct BoxOfficeSessions;
From here, it's the same #[session] parameter the box office uses. The session's subject is the customer's id at the box office:
#[singleton]
#[infers_authenticated_user(user_model = Fan)]
pub struct FanProvider;
impl FanProvider {
#[infer_from_request]
pub fn infer(
&self,
#[session(issuer = box_office)] session: Option<Session>,
) -> anyhow::Result<AuthenticatedUserOutcome<Fan>> {
Ok(match session {
Some(session) => AuthenticatedUserOutcome::Authenticated(Fan {
customer_id: session.subject,
}),
None => AuthenticatedUserOutcome::Anonymous,
})
}
}
#[singleton]
#[responds_to_http(method = RouteMethod::Get, path = "/", server = "public")]
pub struct ShowShop;
impl ShowShop {
#[process]
pub fn respond(&self, #[authenticated_user] fan: Option<Fan>) -> anyhow::Result<Response> {
Ok(Response::text(
200,
match fan {
Some(_) => "Welcome back! Box Office fans get 10% off tour shirts.",
None => "Sign in at the box office to unlock your fan discount.",
},
))
}
}
The shop refreshes sessions through SPIFFE, so its serve asks for the SPIRE agent:
$ BOX_OFFICE_COOKIE_DOMAIN=tickets.example.com \
BOX_OFFICE_SESSION_REFRESH_URL=https://box-office.internal:9443/sessions/refresh \
merch_shop serve \
--public-addr 0.0.0.0:8080 --public-url https://merch.tickets.example.com \
--spiffe-trust-domain example.org --spire-agent-addr unix:///run/spire/agent.sock
Restocking from the back office
When a concert sells out, the staff order more shirts. The shop's stock API should only take orders from the back office. The box office declares a resource for it:
use margaret::framework::macros::issues_resource_tokens;
#[issues_resource_tokens(merch_stock, audience = "https://merch.tickets.example.com/stock")]
pub struct MerchStock;
The back office is already a client of the box office. It gains the client credentials grant, for that one resource:
#[admits_oauth_client(
back_office_app,
authentication = ClientAuthenticationMethod::PrivateKeyJwt(
client_credentials(scopes = []),
keys = ClientKeys::Own,
),
authorization_code(
consent = ConsentPolicy::Implicit,
id_token_signing = IdTokenSigning::EllipticCurve,
redirect_routes = [FinishStaffSignIn],
scopes = [OpenidScope],
),
client_id = "back-office",
resources = [merch_stock],
)]
pub struct BackOfficeApp;
Because the back office is a client of its own application, Margaret generates a ResourceCredentials for every resource it may use, in crate::margaret::oauth_clients::back_office::resources::merch_stock. It needs no target: the resource is part of its type.
pub async fn restock(&self, artist: &str, shirts: u32) -> anyhow::Result<()> {
let token = match self.credentials.access_token().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}")
}
};
self.http
.put(self.merch_url.join("stock")?)
.bearer_auth(token.secret())
.json(&json!({ "artist": artist, "shirts": shirts }))
.send()
.await?
.error_for_status()?;
Ok(())
}
A POST /merch/restock route on the admin server takes the order as JSON, from a signed-in staff member, and calls restock.
On the shop's side, a second trust for the same issuer accepts access tokens for the stock audience. A bearer token from it is read with AccessTokenProfile:
#[verifies_tokens_from_issuer(
box_office_stock,
audience = "https://merch.tickets.example.com/stock",
issuer = "https://tickets.example.com",
keys = IssuerKeys::Discovered
)]
pub struct BoxOfficeStock;
#[singleton]
#[infers_authenticated_user(user_model = StockKeeper)]
pub struct StockKeeperProvider;
impl StockKeeperProvider {
#[infer_from_request]
pub fn infer(
&self,
#[bearer_token(issuer = box_office_stock)] token: Option<
VerifiedJwt<StockClaims, AccessTokenProfile>,
>,
) -> anyhow::Result<AuthenticatedUserOutcome<StockKeeper>> {
Ok(match token {
Some(verified) => AuthenticatedUserOutcome::Authenticated(StockKeeper {
client_id: verified.claims.client_id,
}),
None => AuthenticatedUserOutcome::Anonymous,
})
}
}
The shop's PUT /stock route asks for the StockKeeper. Nobody but the back office can order shirts.