Container Registry Integrations
Mint short-lived pull tokens for OCI registries (quay.io, ghcr.io, atcr.io, pkg.dev)
The container registry integrations (`quay`, `ghcr`, `atcr`, `gar`) are
**session-mint** integrations: exe.dev holds a durable registry credential —
a robot account, a personal access token, an app password, or a service
account key — 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.
## Google Artifact Registry (`gar`)
- Credential: a **service account JSON key**. The username is always the
literal `_json_key` (the integration hardcodes it — you supply only the
key). Grant the SA `roles/artifactregistry.reader` to pull,
`roles/artifactregistry.writer` to push.
- Paste the key as a **single line**: the downloaded key file is multi-line
JSON whose newlines are only formatting, so flatten it losslessly with
`tr -d '\n' < key.json` (or pass `--key=-` and pipe the file on stdin from
the CLI).
- Token realm: `/v2/token`. Minted tokens are opaque (base64 JSON, not a
JWT), observed TTL **43200s (12h)**.
- Regions: the default target is `us-docker.pkg.dev`. For any other region
pass `--base-url` — the multi-regions `europe-docker.pkg.dev` /
`asia-docker.pkg.dev`, or a regional host such as
`us-central1-docker.pkg.dev` / `europe-west1-docker.pkg.dev`. One
integration per region host; the image path is always
`PROJECT/REPOSITORY/IMAGE`.
- **Pull** (stock docker, zero config, no login):
```
docker pull <name>.int.exe.xyz/PROJECT/REPOSITORY/IMAGE:TAG
```
- **Push** works the same way — tag with the integration hostname and push;
docker re-runs the token dance with `push,pull` scope automatically:
```
docker tag myimage <name>.int.exe.xyz/PROJECT/REPOSITORY/IMAGE:TAG
docker push <name>.int.exe.xyz/PROJECT/REPOSITORY/IMAGE:TAG
```
Blob uploads ride through the integration (GAR answers upload legs with
relative `Location` headers, which the client resolves against the
integration hostname).
- **Anonymous-mint caveat**: GAR's realm answers unauthenticated mints with
HTTP 200 and a degraded token (usable only for public repos). A 200 from
the realm therefore does not by itself prove the key worked — the
integration's verify sends the key and relies on GAR rejecting bad keys
(401 for malformed, 404 `NAME_UNKNOWN` for well-formed-but-unknown), both
non-2xx.
- The realm also accepts `oauth2accesstoken:<OAuth2 access token>` as Basic
— the integration does not use this form (access tokens expire hourly;
the `_json_key` form is the durable one).