OpenID Connect provider

The fan club gives early entry to members who hold tickets. Its website wants a "Sign in with Box Office" button, so fans don't need yet another account, and it wants to see which tickets a fan holds. The box office already issues tokens to the door scanner. With a few more declarations, it becomes a full OpenID Connect provider.

Admitting the fan club

The fan club signs fans in with the authorization code flow. It authenticates with its own published keys, like the door scanner, and the fan decides what to share on a consent page:

#[admits_oauth_client(
    fan_club,
    authentication = ClientAuthenticationMethod::PrivateKeyJwt(
        introspection,
        keys = ClientKeys::Published(
            jwks_uri = "https://fanclub.example.org/.well-known/jwks.json",
            signing = JwsAlgorithm::Es256,
        ),
    ),
    authorization_code(
        consent = ConsentPolicy::Prompted,
        id_token_signing = IdTokenSigning::Rsa,
        redirect_uris = ["https://fanclub.example.org/callback"],
        scopes = [OpenidScope, EmailScope],
        refresh_token,
    ),
    client_id = "fan-club",
    resources = [partner_api],
)]
pub struct FanClubClient;

Every choice is part of the declaration:

  • authorization_code lists where fans may be sent back to, which scopes the fan club may ask for, and how ID tokens are signed. The fan club runs PHP, which verifies RSA signatures most easily.

  • refresh_token lets the fan club stay connected after the 15-minute access token expires.

  • introspection lets the fan club's backend ask the box office whether a token is still active, instead of checking signatures itself.

  • resources lists what the fan club's access tokens are for. Here, a new resource for the partner API:

use margaret::framework::macros::issues_resource_tokens;

#[issues_resource_tokens(partner_api, audience = "https://tickets.example.com/api")]
pub struct PartnerApi;

EmailScope is the scope from Sign in with Google, and OpenidScope comes with Margaret. The same scope type works for both directions.

The endpoints

The authorization code flow needs more endpoints than the scanner did. The framework serves all of them:

#[responds_to_http(
    method = RouteMethod::Get,
    path = "/authorize",
    server = "public"
)]
#[serves_oidc_endpoint(OidcEndpoint::Authorization)]
pub struct Authorize;
#[responds_to_http(
    max_body_bytes = 16_384,
    method = RouteMethod::Post,
    path = "/authorize/consent",
    server = "public"
)]
#[serves_oidc_endpoint(OidcEndpoint::Consent(view = ConsentPage))]
pub struct DecideConsent;

OidcEndpoint::Userinfo at GET /userinfo, OidcEndpoint::Revocation at POST /revoke, and OidcEndpoint::Introspection at POST /introspect complete the set. Margaret knows which endpoints your clients need: a client with refresh_token needs revocation, one with introspection needs introspection, and an endpoint nobody can use is a build error. The discovery document lists them all:

$ curl https://tickets.example.com/.well-known/openid-configuration
{"authorization_endpoint":"https://tickets.example.com/authorize",...,"grant_types_supported":["authorization_code","client_credentials","refresh_token"],...,"issuer":"https://tickets.example.com",...,"scopes_supported":["email","openid"],...,"userinfo_endpoint":"https://tickets.example.com/userinfo"}

The schema command now also includes the framework's tables for pending authorizations, authorization codes, and refresh tokens.

You design the consent page. It's a view that takes ConsentPageProps: the client, the scopes it asks for, and the URL to post the decision to:

impl RendersView for ConsentPage {
    type Props<'props> = ConsentPageProps<'props, Routes>;

    fn render(
        &self,
        ConsentPageProps {
            consent:
                ConsentRequest {
                    client_id,
                    id,
                    scopes,
                },
            decision_url,
            routes,
        }: Self::Props<'_>,
    ) -> anyhow::Result<Markup> {
        self.layout.render(LayoutProps {
            asset_bag: AssetBag::new(),
            title: "Connect your account".to_string(),
            content: html! {
                h1 { (client_id) " wants to use your Box Office account" }
                ul {
                    @for scope in scopes {
                        li { (scope.as_str()) }
                    }
                }
                form method="post" action=(decision_url) {
                    input type="hidden" name="id" value=(id);
                    button type="submit" name="decision" value="approve" { "Allow" }
                    button type="submit" name="decision" value="deny" { "Deny" }
                }
                a href=(routes.public.list_concerts.url()) { "Back to the box office" }
            },
        })
    }
}

The form posts the id and a decision of approve or deny. Margaret sends authorization and consent pages with frame-ancestors 'none', so no other site can frame them to trick fans into clicking "Allow".

Signing fans in

The provider recognizes fans by the box office session from Sessions. A fan who is signed in at the box office sees the consent page, and after "Allow", goes back to the fan club with a code:

$ curl -i -X POST https://tickets.example.com/authorize/consent \
    -H "cookie: $BOX_OFFICE_SESSION" -d "id=7b2a912c-dbe0-4b62-ad06-40a2c419df78&decision=approve"
HTTP/1.1 303 See Other
location: https://fanclub.example.org/callback?code=Zn3ILFFw_BKZfJLQgDpViRYR_KNHwk6n_x2pA0OuKOo&iss=https%3A%2F%2Ftickets.example.com&state=xyz
content-security-policy: frame-ancestors 'none'

The fan club exchanges the code at the token endpoint for an ID token, an access token, and a refresh token. A fan who isn't signed in at the box office gets a short 401 message instead, asking them to sign in at the box office first and then continue.

Userinfo claims

The userinfo endpoint answers with the fan's claims. You decide what they are, based on the scopes the fan granted:

impl ProvidesUserinfoClaims for FanProfileClaims {
    type Claims = FanProfile;

    async fn claims(&self, grant: &UserinfoGrant) -> anyhow::Result<UserinfoClaims<FanProfile>> {
        Ok(match self.customers.find(grant.subject).await? {
            Lookup::Found(customer) => UserinfoClaims::Found(FanProfile {
                email: grant.includes::<EmailScope>().then_some(customer.email),
            }),
            Lookup::Missing => UserinfoClaims::SubjectUnknown,
        })
    }
}

FanProfileClaims is a singleton with #[provides_userinfo_claims], and FanProfile is a serializable struct with an optional email. grant.includes::() checks the scope by its type, never by its name.

A partner API

The fan club's access tokens are for the partner_api resource. A fan's tickets are just a route away. The provider reads the access token with #[bearer_token], and the token's subject is the customer's id:

#[singleton]
#[infers_authenticated_user(user_model = PartnerFan)]
pub struct PartnerFanProvider;

impl PartnerFanProvider {
    #[infer_from_request]
    pub fn infer(
        &self,
        #[bearer_token(resource = partner_api)] token: Option<
            VerifiedJwt<PartnerFanClaims, AccessTokenProfile>,
        >,
    ) -> anyhow::Result<AuthenticatedUserOutcome<PartnerFan>> {
        Ok(match token {
            Some(verified) => AuthenticatedUserOutcome::Authenticated(PartnerFan {
                customer_id: verified.claims.sub,
            }),
            None => AuthenticatedUserOutcome::Anonymous,
        })
    }
}

PartnerFan is its own user type, separate from Customer, because it's recognized in a different way: by a bearer token instead of a session. The route GET /api/me/tickets asks for it with #[authenticated_user] fan: PartnerFan, loads the customer's orders, and answers with their tickets as JSON.