Troupeby AliveDocsSign in

Troupe Cloud

CI without stored secrets

A trusted issuer lets a CI system exchange its own short-lived OIDC token for a Troupe access token, so no API key sits in the CI system's secret store. This page shows GitHub Actions from registration to workflow, the checks Troupe makes, other issuers, and sign-in connections for people.

How the exchange works

  1. An owner or admin registers the issuer in the organization: whose tokens to accept, which claims they must carry, and which project and scopes the resulting Troupe token gets. Troupe answers with an audience that belongs to this registration alone.
  2. The CI job asks its provider for an OIDC token with that audience.
  3. The job posts the token to https://api.troupe.ali.ve/oauth/token.
  4. Troupe checks it and answers with a Troupe access token for one project, good for at most 5 minutes.

The registration is chosen by the token's issuer and audience together, so a token minted for another organization's registration never lands in yours.

Register GitHub Actions

On the organization's Identity page choose Register an issuer, then GitHub Actions (CI OIDC). The page fills in a registration as JSON. Replace the two placeholders and register; it needs a password confirmation.

JSON
{
  "name": "GitHub Actions",
  "issuer": "https://token.actions.githubusercontent.com",
  "jwksUri": "https://token.actions.githubusercontent.com/.well-known/jwks",
  "acceptedTyp": ["JWT"],
  "allowedAlgs": ["RS256"],
  "clientBinding": { "claim": null, "allowed": [] },
  "conditions": [{ "claim": "repository_owner_id", "op": "eq", "value": "<your GitHub owner id>" }],
  "scopeSource": "fixed",
  "fixedScopes": [
    "devices:read", "devices:acquire", "devices:drive", "apps:install",
    "logs:read", "runs:read", "runs:report"
  ],
  "scopeCeiling": [
    "devices:read", "devices:acquire", "devices:drive", "apps:install",
    "logs:read", "runs:read", "runs:report"
  ],
  "subjectTemplate": "gha:{repository_id}:{ref}",
  "projectMapping": "fixed",
  "projectId": "<prj_… of the project its runs use>",
  "maxTokenLifetimeSeconds": 600,
  "preset": "github-actions"
}
FieldWhat to put
conditionsClaims a token must carry. GitHub's issuer serves every GitHub user, so a registration for it needs at least one
fixedScopesThe scopes every exchanged token gets. Add builds:read, builds:write and artifacts:read for a job that builds
scopeCeilingThe most the registration may ever grant: at least fixedScopes, at most your own scopes, never org:manage
projectIdThe one project every exchanged token works in
subjectTemplateHow the caller is named in Troupe's records, from the token's claims
maxTokenLifetimeSecondsThe longest life of an accepted token, from 1 to 600 seconds; 60 when left out

Bind the registration as tightly as the job allows. Every condition must hold, and a claim the token lacks fails its condition.

JSON
"conditions": [
  { "claim": "repository_owner_id", "op": "eq", "value": "1234567" },
  { "claim": "repository_id", "op": "eq", "value": "7654321" },
  { "claim": "ref", "op": "glob", "value": "refs/heads/main" },
  { "claim": "environment", "op": "eq", "value": "production" }
]

eq compares exactly, glob takes * and ?, and in takes a list of values. Use GitHub's numeric ids, not names: a name can be registered again by someone else after its owner deletes it. gh api users/<owner> --jq .id prints an owner's id and gh api repos/<owner>/<repo> --jq .id a repository's.

After you register, the page shows the audience, of the form urn:troupe:<environment>:issuer:<issuer id>. The workflow needs it.

The workflow

The job needs the id-token: write permission. The first step asks GitHub for a token with the registration's audience and exchanges it; the steps after it use the Troupe token.

YAML
name: cloud
on: push

permissions:
  contents: read
  id-token: write

jobs:
  devices:
    runs-on: ubuntu-latest
    steps:
      - name: Exchange the job's OIDC token for a Troupe token
        uses: actions/github-script@v7
        with:
          script: |
            const audience = 'urn:troupe:…' // the audience the Identity page shows
            const answer = await fetch('https://api.troupe.ali.ve/oauth/token', {
              method: 'POST',
              headers: { 'content-type': 'application/x-www-form-urlencoded' },
              body: new URLSearchParams({
                grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
                subject_token: await core.getIDToken(audience),
                subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
                resource: 'https://api.troupe.ali.ve/v1',
              }),
            })
            const body = await answer.json()
            if (!answer.ok) throw new Error(`${body.error}: ${body.error_description}`)
            core.setSecret(body.access_token)
            core.exportVariable('TROUPE_TOKEN', body.access_token)

      - name: List the devices the job can start
        run: |
          curl --fail-with-body \
            "https://api.troupe.ali.ve/v1/catalog/device-types?platform=ios-simulator" \
            --header "Authorization: Bearer $TROUPE_TOKEN" \
            --header "Troupe-Contract: 1.0"

From here the job calls the API as it would with a key. The token works in the registration's project, so sessions.create needs no Troupe-Project header.

An exchanged token lasts at most 5 minutes and cannot be refreshed. A job that works longer runs the exchange step again before its next calls: each exchange needs a new token from GitHub, because Troupe accepts a token once. A job that waits on a long build is better served by an API key with the purpose ci.

The exchange request

The request is a form post (application/x-www-form-urlencoded), each parameter at most once.

ParameterValue
grant_typeurn:ietf:params:oauth:grant-type:token-exchange
subject_tokenThe CI system's token
subject_token_typeurn:ietf:params:oauth:token-type:jwt
resourcehttps://api.troupe.ali.ve/v1 for the API, or https://api.troupe.ali.ve/mcp for MCP
scopeOptional: fewer scopes than the registration grants, separated by spaces
audienceOptional: if sent, it must be the registration's audience
JSON
{
  "access_token": "…",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "devices:read devices:acquire devices:drive apps:install logs:read …",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}

scope lists the scopes the token holds, shortened here. The token ends when the CI system's token ends, and after 5 minutes at the latest. It is good for the one resource named: a token for the API is refused at /mcp, and the other way round. Like every token, it never makes a change that grants access. account.get describes it as delegated, with the grant exchange, its project, its scopes and its subject, ext:<issuer id>:<subject>.

What Troupe checks

A token is accepted when all of this holds:

  • it is at most 8 KiB, and its header's alg and typ are ones the registration allows;
  • its iss and its single aud select an active registration;
  • its signature verifies against the issuer's published keys;
  • it carries exp, iat, jti and sub, is not expired or issued in the future (30 seconds of clock difference are allowed), and lives no longer than maxTokenLifetimeSeconds;
  • every condition holds;
  • its jti was not used before.

A refusal is an OAuth error, with HTTP 400 unless noted:

errorWhen
invalid_grantNo registration matches the issuer and audience, the signature or a claim did not verify, a condition did not hold, the token lives too long, it was used before, or the registration is suspended
invalid_requestA parameter is missing or repeated, or the token is not a JWT
invalid_targetresource is neither the API nor /mcp
invalid_scopeNone of the requested scopes is allowed
temporarily_unavailableThe issuer's keys could not be fetched: HTTP 503, with Retry-After
slow_downMore than 120 token requests a minute from one address: HTTP 429, with Retry-After

Suspend, reactivate, remove

On the Identity page, Suspend stops a registration: exchanges are refused, and tokens already issued get SERVICE_SUSPENDED from their next request. Reactivate needs a password confirmation; Remove ends it for good. Every owner is mailed about each change to a registration. The procedures are in the issuers group.

Other issuers

Any issuer that publishes its keys as a JWKS can be registered: start from An empty registration, or call issuers.create. Beyond the fields above:

FieldMeaning
allowedAlgsEdDSA, ES256 or RS256
clientBindingA client_id or azp claim that must be one of allowed
scopeSource: "claim"The token's own scope or scp claim names its scopes, cut to scopeCeiling
projectMapping: "require-existing"The claim named by subtenantClaim must equal an existing project's externalRef
projectMapping: "auto-create"The same, and a project is created for a new value, up to maxProjects

The two mappings by claim give each of the issuer's sub-tenants a project of its own, so they need project placement.

Sign-in connections

A sign-in connection lets people sign in to Troupe Cloud with your OpenID Provider instead of a Troupe password. Add one on the Identity page, with a password confirmation:

FieldMeaning
NameWhat people see
IssuerYour provider's issuer URL. Troupe reads its discovery document, which must name this issuer exactly
Client id, client secretThe client you registered for Troupe at the provider. The secret is stored encrypted and never shown again
Scopesopenid email profile by default

The connection's row shows its sign-in path; a browser starts the sign-in there.

  • A person's identity is the provider's subject within this one connection, never an email address. A person with a Troupe account links the provider to it by opening the sign-in path with ?intent=link while signed in; an email alone never links two accounts.
  • A first sign-in creates an account only for a verified email address that no Troupe account uses, under one of the connection's verified domains. A connection starts with none: until Troupe has verified a domain for it, it signs in only people who linked an account they already had.
  • A sign-in through the provider counts as a password confirmation only by the time the provider reports the person signed in there (auth_time). A provider that reports none cannot confirm changes that grant access.

Removing a connection ends its links and the memberships that came from them; the accounts stay. The procedures are in the connections group.

Next

esc

    Type to search every page, the protocol reference and the cloud API.