Container Registry Integrations
Mint short-lived pull tokens for OCI registries (quay.io, ghcr.io, atcr.io)
The container registry integrations (`quay`, `ghcr`, `atcr`) are
**session-mint** integrations: exe.dev holds a durable registry credential —
a robot account, a personal access token, or an app password — and your VM
exchanges it for a short-lived registry token without ever seeing it.
They all front the same thing: the standard [OCI distribution token
flow](https://distribution.github.io/distribution/spec/auth/token/), the
same dance `docker login` performs. The only exe.dev-specific part is the
hostname; scope grammar, response shape, and error codes are the vendor's.
## How it works
1. `GET` the registry's **token realm** through the integration hostname,
choosing your own scope:
```
curl "http://<name>.int.exe.xyz/v2/auth?service=quay.io&scope=repository:<org>/<repo>:pull"
```
2. The proxy injects your stored credential as HTTP Basic. The realm path is
the **only** path the integration proxies (path-gated), so the credential
can only ever be presented to the registry's own token endpoint.
3. The vendor's response comes back to you verbatim. The `token` field is a
short-lived registry token.
4. Use it directly against the registry — those requests do **not** go
through the integration:
```
curl -H "Authorization: Bearer $TOKEN" https://quay.io/v2/<org>/<repo>/manifests/latest
```
5. When the token expires (the registry answers 401), mint a fresh one.
React to the 401 rather than tracking TTLs.
The token realms are `GET`-only (`POST` returns 405) — this is the OCI
registry token endpoint, not an RFC 6749 OAuth token endpoint.
## Clients
The minted token is a standard OCI registry bearer token; what varies is how
each client accepts it:
- `curl` with `Authorization: Bearer $TOKEN` (as above),
- `skopeo` with `--registry-token $TOKEN` (or `--src-registry-token` /
`--dest-registry-token` for `skopeo copy`),
- `crane` / `oras` / anything built on go-containerregistry or containerd
resolvers: put it in a Docker config file as `registrytoken` (below),
- `docker` itself, via the little-known `registrytoken` field — unlike
`auths.auth` (Basic) or `identitytoken`, this field is sent as
`Authorization: Bearer` and skips the token dance entirely:
```json
{ "auths": { "quay.io": { "registrytoken": "<minted JWT>" } } }
```
Point `DOCKER_CONFIG` at a directory holding that `config.json` (or merge
into `~/.docker/config.json`) and `docker pull quay.io/<org>/<repo>` works
with no `docker login`.
Two caveats with `registrytoken`: it is static — docker will NOT re-mint
when the token expires or when it needs a scope the token lacks, you get a
plain 401 and must refresh the file yourself (mint with every scope you
need: the `scope` parameter repeats). And credential helpers cannot supply
it — it lives only in the config file.
Alternatively, outside the integration entirely: `docker login <registry>`
with your own credentials on the VM. That places the durable credential on
the VM — exactly what the integration exists to avoid — but restores
docker's automatic re-mint behavior.
## quay.io (`quay`)
- Credential: a **robot account** (`myorg+ci` / token), created under
Organization or User Settings → Robot Accounts. Your own quay.io username
and password also work.
- Token realm: `/v2/auth`. The minted token is an RS256 JWT, observed TTL
**3600s**, response field `token`.
- Bad credentials are rejected 401 `Invalid Username or Password`.
- Path-gate caveat: the `/v2/auth` prefix also matches data-plane paths of
repositories whose namespace starts with `auth` (e.g. `/v2/authzed/...`).
Such requests carry the credential to quay.io itself — never to a third
party — so this is a scope wrinkle, not an exposure.
- Self-hosted Red Hat Quay / Project Quay: pass `--base-url`. The realm path
can differ per install; check the `WWW-Authenticate` header returned by
`GET /v2/` on your registry.
## GitHub Container Registry (`ghcr`)
- Credential: a GitHub **personal access token**. Classic PAT with
`read:packages` (plus `write:packages` to push) for private packages; a
fine-grained PAT authenticates at the realm and can pull public packages.
The realm authenticates by the PAT alone — any non-empty username passes
(an empty one is rejected), though `docker login` wants your real one.
- Token realm: `/token`.
- **Important deviation**: for PAT-authenticated mints, ghcr's `token` is
your **PAT base64-encoded**, not a short-lived scoped JWT. Treat every
minted token as equivalent to the PAT itself and scope the PAT
accordingly: `read:packages` only, no `repo` scope, with an expiry.
(Anonymous scoped mints for public images do return opaque short-lived
pull tokens.)
- Bad credentials are rejected **403** (not 401).
## atcr.io (`atcr`)
- Credential: your ATProto handle + a dedicated **app password** (create one
in your PDS/Bluesky settings; never your account password).
- Token realm: `/auth/token`. The response carries both `token` and
`access_token` (identical values) plus `expires_in`/`issued_at`.
- The JWT lives about **45 seconds** — use it immediately and expect to
re-mint often.
- The realm does not accept a minted JWT as the Basic password (401); only
the app password authenticates.