Skip to content

Add a Teleport-authenticated human token path for one exact repository - #73

Closed
portswigger-amason wants to merge 1 commit into
mainfrom
PLAT-834-human-token-path
Closed

portswigger-amason wants to merge 1 commit into
mainfrom
PLAT-834-human-token-path

Conversation

@portswigger-amason

Copy link
Copy Markdown
Contributor

Lets a person authenticated by Teleport, holding an approved access request, obtain a GitHub App installation token for one exact repository. The existing workload paths are unchanged.

POST /human/installation-token/{github-app}/{owner}/{repo}

Why this is separate code, not a mode on the existing paths

/installation-token/... /human/installation-token/...
Validated by authzoo, against [[role]] idcat's own validator, against [[human-role]]
Authorized by allowed-roles, [[installation-policy]], [[owner-policy]] [[human-policy]] only
Repository may be a glob exactly one, no wildcards
Permissions required required
On no match falls back to allowed-roles refused
--disable-auth honoured not honoured
Token cache shared partitioned by validated subject and access request

A request that does not match exactly one [[human-policy]] is refused before the token is validated and before GitHub is contacted. A workload token cannot satisfy a human policy, and a human token cannot satisfy a workload one.

Why validation does not go through authzoo

authzoo cannot express what a Teleport token needs, and RoleConfig is deny_unknown_fields. Re-checked against 0.1.5 — whose entire diff from 0.1.4 is a default-algorithm list gaining ES256 and two log levels swapping — so all four gaps stand:

  • Array-valued role claims. ValidatedClaims::claim_value ends in .and_then(Value::as_str), so Teleport's roles array reads back as absent. Membership is now tested against the array by exact string equality. A scalar claim of the same name is refused rather than treated as a single-element array, and a role that merely contains the required name does not match.
  • Explicit JWKS URL. Key discovery is only ever GET {issuer}/.well-known/openid-configuration. [[human-role]] takes a jwks-url, which must end in /.well-known/jwks.json.
  • The claims, not just role names. validate returns Vec<String>, discarding sub. A person-level issuance record needs the subject.
  • One parse per request. authzoo validates once per configured role, and validate_role_with_claims builds a fresh validator per candidate policy. The human path picks one issuer from configuration and parses once, against a cached JWKS.

The audience is the Teleport application uri, which is not the human-facing hostname; tests pin both directions. Only asymmetric algorithms parse for a human role — a shared secret able to verify such a token could also mint one.

Other changes

  • Token cache keyed by requester. Workload callers keep sharing an entry; a person's entry is partitioned by validated subject and access-request id, so two people never receive the same token and a fresh approval does not revive the previous one's token. A human request always uses InstallationTarget::Repository.
  • Issuance record. Every allow and deny is logged at info with subject, Teleport request, App, exact repository, exact permissions, expiry and a truncated token digest. A test captures the emitted line and asserts the token is absent from it.
  • Credential helper. /bin/sh -c replaced by a fixed executable plus an argument array. token-command still works and warns — a backwards-compatible deprecation. A new repository setting scopes the helper to one repository; for anything else it prints nothing, so Git falls through to other helpers.
  • Load-test harness (examples/human_route_load_test.rs) for the invalid-token path. It requires an explicit ceiling, duration, permitted latency change and environment, and refuses to run without them or against a production-looking target. Built, not run.
  • Startup validation now accepts a deployment configuring only [[human-role]], and an App authorized only by a human-policy. The "this App authorizes nothing" guard names all four policy kinds.

Testing

178 tests (148 idcat + 30 helper), up from 74 before this work. cargo fmt --check, cargo clippy --no-deps --all-targets and all three CI matrices clean.

Every token in the tests is synthetic, signed with an RSA key generated in-process (src/testkeys.rs). No test touches the network or needs a real credential.

The two properties that matter were mutation-tested rather than assumed: removing the cache partition fails 3 tests, and adding an allowed-roles fallback to the human path fails 2. An earlier round of this found a real gap — the fail-closed check only held for Apps with no allowed-roles — which is now covered.

Not included

No real issuer, application uri, hostname, App id, role or repository: the deployment values are not in this PR and the placeholders are not real. Teleport role configuration, ingress and the App installation are separate, owner-approved changes.

…ository

Lets a person authenticated by Teleport, holding an approved access request,
obtain a GitHub App installation token for one exact repository. The existing
workload paths are unchanged.

The human path is separate code, not a mode on the workload ones. A request that
does not match exactly one `[[human-policy]]` is refused rather than retried
against `allowed-roles`, there are no repository wildcards, `permissions` is
required, and `--disable-auth` does not apply.

Validation lives in idcat rather than authzoo, because authzoo cannot express
what a Teleport token needs and `RoleConfig` denies unknown fields. Checked
again against 0.1.5, which changes only its default algorithm list and two log
levels, so all four gaps remain:

  * `ValidatedClaims::claim_value` ends in `.and_then(Value::as_str)`, so an
    array-valued `roles` claim reads back as absent. Membership is now tested
    against the array by exact string equality; a scalar claim of the same name
    is refused rather than accepted as a single-element array, and a role that
    merely contains the required name does not match.
  * Key discovery is only ever `GET {issuer}/.well-known/openid-configuration`.
    `[[human-role]]` takes an explicit `jwks-url`, which must end in
    /.well-known/jwks.json.
  * `validate` returns `Vec<String>` of role names, discarding `sub`. The human
    validator returns the claims, so an issuance record can name the person.
  * authzoo validates once per configured role, and idcat's
    `validate_role_with_claims` builds a fresh validator per candidate policy.
    The human path selects one issuer from configuration and parses once,
    against a cached JWKS.

The audience is the Teleport application uri, which is not the human-facing
hostname; tests pin both directions. Only asymmetric algorithms parse for a
human role, since a shared secret able to verify such a token could mint one.

The installation-token cache is now keyed by requester. Workload callers keep
sharing an entry; a person's entry is partitioned by validated subject and
access-request id, so two people never receive the same token and a fresh
approval does not revive the token issued under the previous one. A human
request always uses an `InstallationTarget::Repository`; the human path has no
owner-wide equivalent and must never acquire one.

Each allow and deny is recorded at info with subject, Teleport request, App,
exact repository, exact permissions, expiry and a truncated token digest. A
test captures the emitted line and asserts the token is absent from it.

The credential helper replaces `/bin/sh -c` with a fixed executable and an
argument array. `token-command` still works and warns, per the chosen
backwards-compatible migration. A new `repository` setting scopes the helper to
one repository; for anything else it prints nothing, so Git receives no
credential from it and falls through.

`examples/human_route_load_test.rs` is the bounded invalid-token load test. It
requires the owner-supplied ceiling, duration, permitted latency change and
environment, and refuses to run without them or against a production-looking
target. It is built and not run: those values have not been supplied.

Startup validation also now accepts a deployment configuring only
`[[human-role]]`, and a GitHub App authorized only by a human-policy. The guard
that every App must authorize something now names all four policy kinds.

docs/human-token-pilot.md no longer claims `DELETE /installation/token` is a
dependable emergency stop. idcat opts into GitHub's stateless (JWT-format)
installation tokens, which are verified from their own signature, so whether
revocation or installation suspension affects an already-issued token is now
something to confirm rather than assume. The one-hour expiry is what the
timebox actually rests on, and that is unaffected.

Deployment inputs are deliberately absent: no repository, role, App, issuer,
application uri, JWKS URL or hostname is invented. The doc lists what must be
supplied, and states plainly that the timebox stops new sessions and expires
tokens but does not remove already-cloned source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@portswigger-amason

Copy link
Copy Markdown
Contributor Author

Closing this in favour of a much smaller approach — thank you for the steer, it was the right call and I should have asked before writing this much.

The point I'd missed: Teleport's own application access control already performs the role check. If only the owning team's role grants access to the idcat application, then a valid JWT carrying that aud is proof the user held the role. idcat re-verifying a roles claim is belt-and-braces, and it was the premise most of this PR was built on.

That removes three of the four reasons this existed:

  • no array-claim problem, because we don't read the array
  • no JWKS plumbing, if validation-key pins the CA public key
  • no availability concern — with validation-key set, resolving_decoding_key never makes a network call, so the uncached /.well-known/openid-configuration request per candidate role doesn't happen

Existing [[installation-policy]] already does one exact repository, and PLAT-711 already made permissions mandatory. So the pilot looks like config against the currently deployed image, with no code change.

Four questions before I draft that config. Concrete case: the research team getting contents: read on portswigger-engineering/desktop.


1. One Teleport app per repository, or one shared app?

If per-repo, each gets its own uri, so audience becomes the discriminator and it scales cleanly:

[[role]]
name = "teleport-desktop"
issuer = "<cluster>"
audience = "https://idcat-desktop.<cluster>/"
validation-key = "<CA public key>"

[[installation-policy]]
github-app = "<dedicated app>"
role = "teleport-desktop"
repository = "portswigger-engineering/desktop"
[installation-policy.permissions]
contents = "read"

If one shared app, nothing in the token distinguishes research-wanting-desktop from another team wanting another repo, and the repository is just a URL path — so any user who can reach the app could request any configured repository.

2. What goes in required-claims?

config.rs:370 refuses an installation-policy with no required-claims and no allow-self-access. A Teleport app JWT's only scalar claims are sub (per-user — puts the gate back in config), iss and aud (both already validated). Is required-claims = { aud = "..." } an acceptable belt-and-braces entry, or would you rather relax that rule when the role pins issuer and audience?

3. Should idcat independently check the group, or is Teleport app gating enough?

Gating alone means a Teleport misconfiguration — granting the app to an additional role — silently widens access to source, and idcat has no way to notice. A second independent check in idcat would catch that, but it needs roles array membership, i.e. ClaimRequirement learning "this array contains this exact string" (ValidatedClaims::claim_value currently ends in .and_then(Value::as_str), so an array reads back as absent).

Worth it for Restricted source, or over-engineering?

4. validation-key or discovery? Does the Teleport proxy serve /.well-known/openid-configuration, or should we pin the JWT CA public key and accept manual rotation?


Two things from this PR may be worth keeping regardless, though neither is needed for a single-repository pilot and I'm not proposing them now:

  • Per-person token caching. InstallationTokenCacheKey is (github_app, target, scope), so two people approved for the same repo within the 50-minute TTL receive the identical token string. Not an access problem — both were approved — but you can't tell them apart afterwards.
  • An issuance record naming the subject. validate() returns Vec<String>, so sub is discarded and nothing can record who received what. Teleport's audit log covers most of this while one role maps to one repo; it stops doing so at two.

Both become relevant at repository number two. Happy to raise them separately then.

Context in PLAT-834.

@portswigger-amason

Copy link
Copy Markdown
Contributor Author

Closing — superseded by the configuration-only approach described above. The branch stays if any of it is wanted later; the per-person caching and issuance-record pieces are the only parts likely to come back, and only once a second repository is in scope.

@portswigger-amason
portswigger-amason deleted the PLAT-834-human-token-path branch October 13, 2026 09:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant