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, 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:

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