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
- 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.
- The CI job asks its provider for an OIDC token with that audience.
- The job posts the token to
https://api.troupe.ali.ve/oauth/token. - 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.
{
"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"
}| Field | What to put |
|---|---|
conditions | Claims a token must carry. GitHub's issuer serves every GitHub user, so a registration for it needs at least one |
fixedScopes | The scopes every exchanged token gets. Add builds:read, builds:write and artifacts:read for a job that builds |
scopeCeiling | The most the registration may ever grant: at least fixedScopes, at most your own scopes, never org:manage |
projectId | The one project every exchanged token works in |
subjectTemplate | How the caller is named in Troupe's records, from the token's claims |
maxTokenLifetimeSeconds | The 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.
"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.
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.
| Parameter | Value |
|---|---|
grant_type | urn:ietf:params:oauth:grant-type:token-exchange |
subject_token | The CI system's token |
subject_token_type | urn:ietf:params:oauth:token-type:jwt |
resource | https://api.troupe.ali.ve/v1 for the API, or https://api.troupe.ali.ve/mcp for MCP |
scope | Optional: fewer scopes than the registration grants, separated by spaces |
audience | Optional: if sent, it must be the registration's audience |
{
"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
algandtypare ones the registration allows; - its
issand its singleaudselect an active registration; - its signature verifies against the issuer's published keys;
- it carries
exp,iat,jtiandsub, is not expired or issued in the future (30 seconds of clock difference are allowed), and lives no longer thanmaxTokenLifetimeSeconds; - every condition holds;
- its
jtiwas not used before.
A refusal is an OAuth error, with HTTP 400 unless noted:
error | When |
|---|---|
invalid_grant | No 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_request | A parameter is missing or repeated, or the token is not a JWT |
invalid_target | resource is neither the API nor /mcp |
invalid_scope | None of the requested scopes is allowed |
temporarily_unavailable | The issuer's keys could not be fetched: HTTP 503, with Retry-After |
slow_down | More 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:
| Field | Meaning |
|---|---|
allowedAlgs | EdDSA, ES256 or RS256 |
clientBinding | A 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:
| Field | Meaning |
|---|---|
| Name | What people see |
| Issuer | Your provider's issuer URL. Troupe reads its discovery document, which must name this issuer exactly |
| Client id, client secret | The client you registered for Troupe at the provider. The secret is stored encrypted and never shown again |
| Scopes | openid 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=linkwhile 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.