Trusting other issuers

Promoters keep their line-ups in git, and they'd like to publish them on every push, instead of emailing files to the staff. The box office already streams line-ups into its importer, in Requests. Now promoters should reach that importer from GitHub Actions, without a password or a shared secret anywhere.

GitHub Actions can hand every workflow run a short-lived token, signed by GitHub, that says which repository and branch it runs for. The box office only has to trust GitHub as an issuer.

Trusting GitHub

GitHub publishes its keys at a fixed address, so IssuerKeys::Published points straight at them. The audience is what workflows ask GitHub to put into their tokens: the box office's own address.

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

#[verifies_tokens_from_issuer(
    github_actions,
    audience = "https://tickets.example.com",
    issuer = "https://token.actions.githubusercontent.com",
    keys = IssuerKeys::Published(
        jwks_uri = "https://token.actions.githubusercontent.com/.well-known/jwks",
    ),
)]
pub struct GithubActions;

Promoters

The staff register each promoter with the repository it publishes from:

#[model(table = "promoters")]
#[derive(Clone)]
pub struct Promoter {
    #[column(primary_key, default = ColumnDefault::UuidV7)]
    pub id: Uuid,
    #[column]
    pub name: String,
    #[column(unique)]
    pub repository: String,
}
$ box_office add-promoter "Riverside Promotions" --repository riverside/lineups
Riverside Promotions publishes from riverside/lineups

add-promoter is a small command that creates the Promoter through the PromoterRepository.

Recognizing the workflow

The promoter is an authenticated user, and its provider reads GitHub's token with #[bearer_token(issuer = github_actions)]. GitHub's tokens are ID tokens, so the profile is IdTokenProfile. Only pushes to main may publish, and anything else is stopped with Interrupted:

use serde::Deserialize;

#[derive(Deserialize)]
pub struct GithubActionsClaims {
    #[serde(rename = "ref")]
    pub git_ref: String,
    pub repository: String,
}
    #[infer_from_request]
    pub async fn infer(
        &self,
        #[bearer_token(issuer = github_actions)] token: Option<
            VerifiedJwt<GithubActionsClaims, IdTokenProfile>,
        >,
    ) -> anyhow::Result<AuthenticatedUserOutcome<Promoter>> {
        let Some(verified) = token else {
            return Ok(AuthenticatedUserOutcome::Anonymous);
        };

        if verified.claims.git_ref != "refs/heads/main" {
            return Ok(AuthenticatedUserOutcome::Interrupted(
                Response::forbidden().into(),
            ));
        }

        Ok(
            match self
                .promoters
                .by_repository(verified.claims.repository)
                .await?
            {
                Lookup::Found(promoter) => AuthenticatedUserOutcome::Authenticated(promoter),
                Lookup::Missing => {
                    AuthenticatedUserOutcome::Interrupted(Response::forbidden().into())
                }
            },
        )
    }

Publishing a line-up

The streaming loop from Requests moved into a ConcertFeed singleton, so the staff's admin route and the promoters' public route share it. The public route asks for the promoter, and passes the body on:

#[singleton]
#[responds_to_http(
    max_body_bytes = 10_485_760,
    method = RouteMethod::Put,
    path = "/lineup",
    server = "public"
)]
pub struct PublishLineup {
    feed: Arc<ConcertFeed>,
}

impl PublishLineup {
    #[constructor]
    pub fn create(feed: Arc<ConcertFeed>) -> anyhow::Result<Self> {
        Ok(Self { feed })
    }

    #[process]
    pub async fn respond(
        &self,
        #[authenticated_user] promoter: Promoter,
        body: RequestBodyStream,
    ) -> anyhow::Result<Response> {
        println!("{} is publishing their line-up", promoter.name);

        self.feed.receive(body).await
    }
}

Margaret recognizes the caller before it reads a single byte of the body. An anonymous upload of 20 megabytes is turned away right away, with a 401, without the box office ever receiving the file:

$ curl -i -T big-file.jsonl https://tickets.example.com/lineup
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer

Exchanging tokens

This works, but it ties the import route to GitHub. Some promoters use other CI systems, and every new one would mean another provider on the route. Instead, the box office can exchange GitHub's token for one of its own, with OAuth 2.0 Token Exchange. The route then only ever sees box office tokens.

Declare a resource for line-up imports, and admit a public client that may exchange tokens for it:

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

#[issues_resource_tokens(lineup_imports, audience = "https://tickets.example.com/lineup")]
pub struct LineupImports;
use margaret::framework::macros::admits_oauth_client;
use margaret::framework::oauth_vocabulary::client_authentication_method::ClientAuthenticationMethod;

#[admits_oauth_client(
    lineup_ci,
    authentication = ClientAuthenticationMethod::None,
    client_id = "lineup-ci",
    resources = [lineup_imports],
    token_exchange(scopes = []),
)]
pub struct LineupCiClient;

The client needs no secret, because the GitHub token it brings is the proof. Which GitHub tokens are good enough is up to you. A singleton with #[exchanges_tokens_from] decides, and picks the subject of the new token: the promoter's id.

#[singleton]
#[exchanges_tokens_from(issuer = github_actions)]
pub struct PromoterExchanger {
    promoters: Arc<PromoterRepository>,
}

impl PromoterExchanger {
    #[constructor]
    pub fn create(promoters: Arc<PromoterRepository>) -> anyhow::Result<Self> {
        Ok(Self { promoters })
    }
}

#[async_trait]
impl ExchangesSubjectTokens for PromoterExchanger {
    type Claims = GithubActionsClaims;
    type Profile = IdTokenProfile;

    async fn exchange(
        &self,
        token: &VerifiedJwt<GithubActionsClaims, IdTokenProfile>,
    ) -> anyhow::Result<SubjectTokenExchange> {
        if token.claims.git_ref != "refs/heads/main" {
            return Ok(SubjectTokenExchange::Refused);
        }

        Ok(
            match self
                .promoters
                .by_repository(token.claims.repository.clone())
                .await?
            {
                Lookup::Found(promoter) => SubjectTokenExchange::Granted {
                    scopes: BTreeSet::new(),
                    subject: promoter.id,
                },
                Lookup::Missing => SubjectTokenExchange::Refused,
            },
        )
    }
}

The promoter's provider now reads box office tokens for the lineup_imports resource, and finds the promoter by the token's subject:

        &self,
        #[bearer_token(resource = lineup_imports)] token: Option<
            VerifiedJwt<LineupClaims, AccessTokenProfile>,
        >,
    ) -> anyhow::Result<AuthenticatedUserOutcome<Promoter>> {
        let Some(verified) = token else {
            return Ok(AuthenticatedUserOutcome::Anonymous);
        };

        Ok(match self.promoters.find(verified.claims.sub).await? {
            Lookup::Found(promoter) => AuthenticatedUserOutcome::Authenticated(promoter),
            Lookup::Missing => AuthenticatedUserOutcome::Anonymous,
        })
    }

The route itself doesn't change. A promoter's workflow asks GitHub for a token, exchanges it at the box office's token endpoint, and publishes:

name: Publish the line-up

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Exchange the GitHub token for a box office token
        run: |
          github_token=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://tickets.example.com" | jq -r .value)
          box_office_token=$(curl -sS https://tickets.example.com/token \
            -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
            -d client_id=lineup-ci \
            -d audience=https://tickets.example.com/lineup \
            -d subject_token_type=urn:ietf:params:oauth:token-type:id_token \
            --data-urlencode "subject_token=$github_token" | jq -r .access_token)
          echo "::add-mask::$box_office_token"
          echo "BOX_OFFICE_TOKEN=$box_office_token" >> "$GITHUB_ENV"
      - name: Publish
        run: |
          curl -sS --fail-with-body -X PUT https://tickets.example.com/lineup \
            -H "Authorization: Bearer $BOX_OFFICE_TOKEN" \
            --data-binary @lineup.jsonl

A token that doesn't verify never reaches your exchanger:

$ curl https://tickets.example.com/token -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
    -d client_id=lineup-ci -d audience=https://tickets.example.com/lineup \
    -d subject_token_type=urn:ietf:params:oauth:token-type:id_token -d subject_token=garbage
{"error":"invalid_request","error_description":"the subject token fails verification"}

Supporting another CI system now means trusting its issuer and adding one more exchanger. The import route never changes again.