Sessions

Returning fans want one place with all their tickets, and they'd rather not type their email at every checkout. Right now, anyone who knows an order's address can see it, too. Time for Box Office accounts.

A fan signs in by clicking a link the box office emails them. Margaret doesn't decide how people prove who they are. It runs the sessions: once you know who someone is, you start a session, and every route can ask for the current customer.

Sessions need a token issuer

A session is backed by signed tokens, so the box office declares itself a token issuer, under the URL its public server will run at:

use margaret::framework::macros::issues_tokens;

#[issues_tokens(box_office, issuer = "https://tickets.example.com")]
pub struct BoxOfficeIssuer;

box_office is a tag: a name the other auth attributes use to refer to this issuer. Then declare the sessions it issues:

use margaret::framework::macros::issues_sessions;
use margaret::framework::sessions::session_cookies::SessionCookies;

#[issues_sessions(
    issuer = box_office,
    audience = "https://tickets.example.com/sessions",
    cookies = SessionCookies::HostOnly
)]
pub struct FanSessions;

A session is two cookies:

  • __Host-margaret-session holds a random secret, valid for three days. The database keeps only its digest.

  • __Host-margaret-session-access holds a signed token, valid for 15 minutes, so most requests never touch the database. When it expires, Margaret checks the secret and signs a new one.

Margaret signs those tokens with keys it creates, rotates every hour, and keeps in your database, so every instance of the box office shares them. The schema command now also includes the framework's own tables for signing keys and sessions, in a separate margaret schema. Recreate your database to get them.

A sign-in link carries a random token. The box office stores only the token's digest, and when it expires:

use chrono::DateTime;
use chrono::Utc;
use margaret::framework::macros::model;

#[model(table = "sign_in_links")]
pub struct SignInLink {
    #[column(primary_key, byte_length = 32)]
    pub digest: Vec<u8>,
    #[column]
    pub email: String,
    #[column]
    #[index]
    pub expires_at: DateTime<Utc>,
}

Margaret ships the pieces for secrets: random_token creates one, and TokenDigest hashes it. A link can be used once. Redeeming it deletes it, but only while it hasn't expired, so two clicks on the same link can't both succeed:

    pub async fn issue(&self, email: String) -> Result<String, ActiveRecordError> {
        let now = self.clock.now();
        let token = random_token();

        SignInLink::query()
            .expires_at
            .below(now)
            .delete(self.database.as_ref())
            .await?;
        SignInLink {
            digest: TokenDigest::of(&token).as_bytes().to_vec(),
            email,
            expires_at: now + TimeDelta::minutes(15),
        }
        .insert()
        .run(self.database.as_ref())
        .await?;

        Ok(token)
    }

    pub async fn redeem(&self, token: &str) -> Result<Option<String>, ActiveRecordError> {
        let now = self.clock.now();

        Ok(
            match SignInLink::query()
                .digest
                .eq(TokenDigest::of(token).as_bytes().to_vec())
                .when(|link| link.expires_at.above(now))
                .delete(self.database.as_ref())
                .await?
            {
                Removal::Removed(link) => Some(link.email),
                Removal::Missing => None,
            },
        )
    }

Issuing a link also sweeps away the expired ones. The sign-in form posts the email address, and the route sends the link through the outbox:

    #[process]
    pub async fn respond(
        &self,
        routes: &Routes,
        views: &Views,
        asset_bag: AssetBag,
        #[form_request(from = RequestInput::Form)] SignInForm { email }: SignInForm,
    ) -> anyhow::Result<Response> {
        let token = self.links.issue(email.clone()).await?;
        let link = addressed(routes.public.show_sign_in_link(token))?.url();

        OutgoingEmail::create(Draft {
            recipient: email,
            subject: "Sign in to the box office".to_string(),
            body: format!("Follow this link within 15 minutes to sign in: {link}"),
            send_after: self.clock.now(),
        })
        .run(self.database.as_ref())
        .await?;
        self.outbox.wake();

        Ok(Response::html(
            200,
            views.sign_in_page.render(SignInPageProps {
                asset_bag,
                message: Some("Check your inbox for a sign-in link."),
                routes,
            })?,
        ))
    }

The answer is the same whether the address belongs to a customer or not, so nobody can find out who has an account.

Starting a session

Email security scanners open links to check them. If opening the link signed the fan in, a scanner would burn it. So GET /sign-in/{token} only shows a button, and the button posts to POST /sign-in/{token}, which redeems the link, finds or creates the customer, and starts the session:

    #[process]
    pub async fn respond(
        &self,
        routes: &Routes,
        #[route_parameter(from = "token")] token: String,
    ) -> anyhow::Result<ResponseContinuation> {
        let Some(email) = self.links.redeem(&token).await? else {
            return Ok(Response::text(410, "This link has expired. Request a new one.").into());
        };
        let customer = self.customers.find_or_create(email).await?;
        let started = self.sessions.start(customer.id, self.clock.now()).await?;

        Ok(started
            .cookie_changes
            .precede(routes.public.my_tickets.see_other().into()))
    }

IssuedSessions comes from crate::margaret::sessions, generated from #[issues_sessions]. start takes the customer's id and the time they proved who they are, and returns the cookies to set. precede puts them on the redirect.

$ curl -i -X POST https://tickets.example.com/sign-in/s5xxE049NafXZxdYDi131UCo8uPlB04Vjbo2RfNa3y8
HTTP/1.1 303 See Other
set-cookie: __Host-margaret-session=...; HttpOnly; SameSite=Lax; Secure; Path=/; Max-Age=259200
set-cookie: __Host-margaret-session-access=...; HttpOnly; SameSite=Lax; Secure; Path=/; Max-Age=900
location: https://tickets.example.com/my-tickets

Click the link a second time, and you get the 410.

Session cookies are Secure, so browsers only send them over HTTPS. Run the public server behind a TLS-terminating proxy, even on the office network.

Recognizing the customer

Write a singleton with #[infers_authenticated_user], naming the user type. Its #[infer_from_request] method asks for the session with #[session], and decides who's there:

#[singleton]
#[infers_authenticated_user(user_model = Customer)]
pub struct CustomerProvider {
    customers: Arc<CustomerRepository>,
}

impl CustomerProvider {
    #[constructor]
    pub fn create(customers: Arc<CustomerRepository>) -> anyhow::Result<Self> {
        Ok(Self { customers })
    }

    #[infer_from_request]
    pub async fn infer(
        &self,
        #[session(issuer = box_office)] session: Option<Session>,
    ) -> anyhow::Result<AuthenticatedUserOutcome<Customer>> {
        let Some(session) = session else {
            return Ok(AuthenticatedUserOutcome::Anonymous);
        };

        Ok(match self.customers.find(session.subject).await? {
            Lookup::Found(customer) => AuthenticatedUserOutcome::Authenticated(customer),
            Lookup::Missing => AuthenticatedUserOutcome::Anonymous,
        })
    }
}

There are three possible answers: Authenticated with the customer, Anonymous for guests, and Interrupted to stop the request with a response of your choice. Each user type has exactly one provider, and asking for a user type without one is a build error. Session subjects are UUIDs, like the customers' primary keys.

Asking for the customer

Ask for the customer with #[authenticated_user]. The checkout no longer needs an email address, and guests get a 401:

    pub async fn respond(
        &self,
        routes: &Routes,
        #[authenticated_user] customer: Customer,
        #[route_parameter(from = "concert")] concert: Concert,
        #[form_request(from = RequestInput::Json)] checkout: ValidationResult<CheckoutRequest>,
    ) -> anyhow::Result<Response> {

Order pages are private now. A customer only sees their own orders:

    pub fn respond(
        &self,
        views: &Views,
        asset_bag: AssetBag,
        #[authenticated_user] customer: Customer,
        #[route_parameter(from = "order")] order: OrderDetails,
    ) -> anyhow::Result<Response> {
        if order.order.customer != Key::of(&customer) {
            return Ok(Response::not_found());
        }

When guests are welcome

Ask for an Option instead. The new My tickets page sends guests to the sign-in form, and shows customers their latest orders, newest first:

    #[process]
    pub async fn respond(
        &self,
        routes: &Routes,
        views: &Views,
        asset_bag: AssetBag,
        #[authenticated_user] customer: Option<Customer>,
    ) -> anyhow::Result<ResponseContinuation> {
        let Some(customer) = customer else {
            return Ok(routes.public.show_sign_in.see_other().into());
        };
        let orders = self.orders.of_customer(&customer).await?;

        Ok(Response::html(
            200,
            views.my_tickets_page.render(MyTicketsPageProps {
                asset_bag,
                customer,
                orders,
                routes,
            })?,
        )
        .into())
    }
    pub async fn of_customer(
        &self,
        customer: &Customer,
    ) -> Result<Vec<OrderDetails>, ActiveRecordError> {
        Ok(Order::query()
            .customer
            .eq(Key::of(customer))
            .id
            .descending()
            .limit::<20>()
            .load::<OrderDetails, _>(self.database.as_ref())
            .await?
            .records)
    }

The concert page asks for an Option too: customers see the seat picker, and guests see a "Sign in to buy tickets" link.

Signing out

Margaret handles signing out. Declare a route, and say where to land afterwards:

use margaret::framework::macros::responds_to_http;
use margaret::framework::macros::serves_session_endpoint;
use margaret::framework::route_method::route_method::RouteMethod;
use margaret::framework::sessions::session_endpoint::SessionEndpoint;

use crate::routes::public::list_concerts::ListConcerts;

#[responds_to_http(
    method = RouteMethod::Post,
    name = "sign_out",
    path = "/sign-out",
    server = "public"
)]
#[serves_session_endpoint(SessionEndpoint::SignOut(landing_route = ListConcerts))]
pub struct SignOut;

There's no #[singleton] and no method: the framework serves this route. It forgets the session, clears both cookies, and redirects to the line-up. My tickets has a "Sign out" button that posts to routes.public.sign_out.url().