# Container Registry Integrations


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.
