# Container Registry Integrations


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).

