Sign in with Google

Some fans don't want to wait for an email. They'd rather click "Sign in with Google". Google is an OpenID Connect provider, and Margaret speaks OpenID Connect as a client: it redirects the fan to Google, checks what Google sends back, and asks you whether to let the fan in.

The rest of the box office doesn't change at all. A fan who signs in with Google gets the same session as one who clicked an email link.

Trusting Google

First, tell Margaret about the issuer whose tokens it should accept. IssuerKeys::Discovered reads Google's discovery document to find its endpoints and keys. The audience is the client id Google gave the box office:

use margaret::framework::macros::verifies_tokens_from_issuer;
use margaret::framework::trusted_issuer::issuer_keys::IssuerKeys;

#[verifies_tokens_from_issuer(
    google,
    audience = "1024-box-office.apps.googleusercontent.com",
    issuer = "https://accounts.google.com",
    keys = IssuerKeys::Discovered
)]
pub struct Google;

The OAuth client

Next, declare the box office as Google's client. It authenticates with the client secret Google issued, read from an environment variable, and asks for the email scope when signing in. Scopes are types, declared once with #[oauth_scope]:

use margaret::framework::macros::oauth_scope;

#[oauth_scope(name = "email")]
pub struct EmailScope;
use margaret::framework::macros::oauth_client;
use margaret::framework::oauth_vocabulary::client_authentication_method::ClientAuthenticationMethod;

use crate::auth::email_scope::EmailScope;

#[oauth_client(
    google_sign_in,
    authentication = ClientAuthenticationMethod::ClientSecretBasic(
        client_secret_from = "GOOGLE_CLIENT_SECRET"
    ),
    client_id = "1024-box-office.apps.googleusercontent.com",
    issuer = google,
    sign_in(scopes = [EmailScope]),
)]
pub struct GoogleSignIn;

The openid scope is always requested on top. serve now refuses to start without GOOGLE_CLIENT_SECRET:

$ box_office serve ...
the environment variable 'GOOGLE_CLIENT_SECRET' is not set

Sign-in routes

Sign-in takes two routes: one that sends the fan to Google, and one that Google sends them back to. The framework serves both, so they're just declarations:

use margaret::framework::macros::responds_to_http;
use margaret::framework::macros::serves_sign_in;
use margaret::framework::oidc_sign_in::sign_in_endpoint::SignInEndpoint;
use margaret::framework::route_method::route_method::RouteMethod;

#[responds_to_http(
    method = RouteMethod::Get,
    name = "start_google_sign_in",
    path = "/auth/google",
    server = "public"
)]
#[serves_sign_in(SignInEndpoint::Start, client = google_sign_in)]
pub struct StartGoogleSignIn;
use margaret::framework::macros::responds_to_http;
use margaret::framework::macros::serves_sign_in;
use margaret::framework::oidc_sign_in::sign_in_endpoint::SignInEndpoint;
use margaret::framework::route_method::route_method::RouteMethod;

use crate::routes::public::my_tickets::MyTickets;

#[responds_to_http(method = RouteMethod::Get, path = "/auth/google/callback", server = "public")]
#[serves_sign_in(SignInEndpoint::Callback(landing_route = MyTickets), client = google_sign_in)]
pub struct FinishGoogleSignIn;

The start route redirects to Google with everything OpenID Connect asks for: a state, a nonce, and a PKCE challenge. It keeps them in a short-lived, signed cookie:

$ curl -i https://tickets.example.com/auth/google
HTTP/1.1 303 See Other
location: https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=1024-box-office.apps.googleusercontent.com&state=D9r1...&redirect_uri=https%3A%2F%2Ftickets.example.com%2Fauth%2Fgoogle%2Fcallback&scope=email+openid&code_challenge=ZCUA...&code_challenge_method=S256&nonce=rQAA...
set-cookie: __Host-margaret-sign-in-nnrp...=...

The callback checks all of it, exchanges the code for tokens, verifies Google's ID token, and then asks you.

Letting fans in

Who may sign in is your decision. A singleton with #[admits_sign_in] answers it. It receives the claims of Google's ID token, in a type you define:

use serde::Deserialize;

#[derive(Deserialize)]
pub struct GoogleClaims {
    pub email: String,
    pub email_verified: bool,
}
#[singleton]
#[admits_sign_in(client = google_sign_in)]
pub struct GoogleFans {
    customers: Arc<CustomerRepository>,
}

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

#[async_trait]
impl AdmitsSignIn for GoogleFans {
    type IdClaims = GoogleClaims;

    async fn admit(
        &self,
        SignedIn { claims, .. }: SignedIn<GoogleClaims>,
        _flow: &SignInFlow,
    ) -> anyhow::Result<SignInAdmission> {
        if !claims.email_verified {
            return Ok(SignInAdmission::Refused);
        }

        let customer = self.customers.find_or_create(claims.email).await?;

        Ok(SignInAdmission::Admitted(customer.id))
    }
}

A fan whose Google address is verified is the same customer as the one who signs in by email. Admitted takes the customer's id, and Margaret starts the session and redirects to the landing_route, here My tickets. The sign-in page gets a link to routes.public.start_google_sign_in.url().

What the build checks

Each client has exactly one start route, one callback route, and one #[admits_sign_in] singleton, and every scope it asks for is declared. Both sign-in routes must be GET routes. Missing or duplicated pieces are build errors.