Authenticating with OCI Registries – GitHub Container Registry (GHCR) Implementation
Update 2024-03-04: Below, I mention that GHCR does not return proper scope in the
www-authenticate
header, which is not correct. GHCR returns the hintrepository:user/image:pull
only if no real repository is requested (e.g. for the/v2/
and_catalog
endpoints). If I request an existing repository (e.g.toddysm/python
) the scope is properly set asrepository:toddysm/python:pull
in thewww-authenticate
header and can be used in the subsequent call to request the token.Additional detail about this is that if you specify valid username and password to the
user/image
scope, you will receive a valid token. However, if it is anonymous request is denied for this call.Another issue with GHCR is that it does not reply with the expiration of the token. I can assume a default of 60 seconds but this is not explicitly communicated by the API. This may result in errors when requesting the APIs and I need to implement preemptive logic to request authentication tokens.
In my last post Authenticating with OCI Registries – Docker Hub Implementation, I went through the authentication flow for Docker Hub. In this one, I will go through the same steps for GitHub Container Registry (aka GHCR) and highlight the most important differences with the Docker Hub flow. As a reminder, here are the authentication scenarios that I will go over:
- Authentication for the root of the registry (i.e https://myregistry.provider.com/)
- Authentication flow for the
/v2/
endpoint - Authentication flow for the
/v2/_catalog
endpoint - Authentication flow for any
/v2/repository
endpoint
GitHub Container Registry (GHCR) Root URL Authentication
GHCR has a single DNS used to access the registry and the authentication endpoint: ghcr.io
. The authentication endpoint is accessible at the ghcr.io/token
path. Requesting the root URL of the registry GET https://ghcr.io
returns the GitHub Packages landing page. The behavior is the same for both HTTP
(insecure) and HTTPS
requests.
GitHub Container Registry (GHCR) /v2/ Endpoint Authentication
As per specification, the /v2/
endpoint returns an authentication challenge.
### Get the /v2/ endpoint ### REQUEST GET https://ghcr.io/v2/ ### RESPONSE HTTP/1.1 401 Unauthorized Content-Type: application/json docker-distribution-api-version: registry/2.0 www-authenticate: Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull" Date: Mon, 05 Feb 2024 04:47:32 GMT Content-Length: 73 X-GitHub-Request-Id: 1E51:2DB2E6:17EDE:3D83A:65C06864 connection: close { "errors": [ { "code": "UNAUTHORIZED", "message": "authentication required" } ] }
The problem with this authentication challenge is that I do not get a valid scope for authentication. If I try to authenticate using the scope specified in the challenge, I get the following error:
### Authenticate for the /v2/ using the default challenge information ### REQUEST GET https://ghcr.io/token?service=ghcr.io&scope=repository:user/image:pull ### RESPONSE HTTP/1.1 403 Forbidden Content-Type: application/json docker-distribution-api-version: registry/2.0 Date: Mon, 05 Feb 2024 04:50:21 GMT Content-Length: 86 X-GitHub-Request-Id: 53D3:309B07:D434:225D0:65C0690D connection: close { "errors": [ { "code": "DENIED", "message": "requested access to the resource is denied" } ] }
This response does not provide any more useful information how to authenticate with the registry’s /v2/
endpoint. Trying to authenticate without scope
information, similar to the Docker Hub approach, returns the same 403 Forbidden error as above. I tried also different approaches like using the user only (i.e. scope=repository:toddysm:pull
) or wildcard (i.e. scope=repository:*:pull
). They both return 400 Bad Request response.
The only way I found to get a valid authentication token for the /v2/
endpoint is to use a valid repository name. For example, requesting a test repository under my username returns the following:
### Authenticate for the /v2/ endpoint ### REQUEST GET https://ghcr.io/token?service=ghcr.io&scope=repository:toddysm/python:pull ### RESPONSE HTTP/1.1 200 OK Content-Type: application/json docker-distribution-api-version: registry/2.0 Date: Mon, 05 Feb 2024 04:59:31 GMT Content-Length: 65 X-GitHub-Request-Id: 29D4:10F50B:10B99A:1D4E0F:65C06B33 connection: close { "token": "djE6dG9kZHl<redacted>" }
With this token, I can authenticate with the /v2/
endpoint and get a response:
### Get the /v2/ endpoint ### REQUEST GET https://ghcr.io/v2/ Authorization: Bearer djE6dG9kZHl<redacted> ### RESPONSE HTTP/1.1 200 OK Content-Type: application/json docker-distribution-api-version: registry/2.0 Date: Mon, 05 Feb 2024 05:01:32 GMT Content-Length: 0 X-GitHub-Request-Id: CC38:1762F4:9382E:13C1B3:65C06BAC connection: close
The interesting part here is that the response has an empty body. Note, this is an empty body and not an empty JSON (i.e. {}
) as the specification requires.
One interesting behavior I noticed is that requesting the /v2
endpoint (without trailing slash) without a token returns authentication challenge but requesting it with a valid token returns 404 Not Found error.
GitHub Container Registry (GHCR) _catalog Authentication Flow
Unlike Docker Hub, GHCR allows you to list the _catalog
for the registry. There are a few specifics about it, though. First, I cannot list the catalog with an anonymous token (i.e. the one that I receive without specifying my credentials). To list the _catalog
, I had to authenticate with my own credentials (again, using a valid repository scope for the token). Here the steps:
### Authenticate with username and password for the /v2/toddysm/python ### REQUEST GET https://ghcr.io/token?service=ghcr.io&scope=repository:toddysm/python:pull,push,delete Authorization: Basic <your_base64_encoded_credentials> ### RESPONSE HTTP/1.1 200 OK Content-Type: application/json docker-distribution-api-version: registry/2.0 Date: Mon, 12 Feb 2024 17:23:50 GMT Content-Length: 69 X-GitHub-Request-Id: A260:2D6237:BC2C:138E3:65CA5426 connection: close { "token": "Z2hwX<redacted>" } ### Get the /v2/_catalog with individual auth token ### REQUEST GET https://ghcr.io/v2/_catalog Authorization: Bearer Z2hwX<redacted> ### RESPONSE HTTP/1.1 200 OK Content-Type: application/json docker-distribution-api-version: registry/2.0 Link: </v2/_catalog?last=0b2ce%2Fcoreutils&n=0>; rel="next" Date: Mon, 12 Feb 2024 17:27:14 GMT Transfer-Encoding: chunked X-GitHub-Request-Id: CF8A:2D239F:A9FF:1285D:65CA54F1 connection: close { "repositories": [ "0-5788719150923125/ctx", "0-5788719150923125/dht", "0-5788719150923125/lab", "0-5788719150923125/src", "0-5788719150923125/uxo", ... ] }
The other interesting thing is that I receive a list of all repositories I have access to with my credentials. My assumption is that this includes every publicly accessible repository in GHCR (including mine). It would be interesting to know whether private repositories I have access to are also included in the _catalog
. I have not dug into this scenario – something to do in the future.
GitHub Container Registry (GHCR) Repository Authentication Flow
As with the Docker Hub investigation, I will do two things here: authenticate using anonymous token to pull from public repositories (I will pull from my test public repository toddysm/python
) and to pull, push, and delete from private repositories (I can use the same repo to test the push and delete capability).
Authentication Flow for Pulling from Public Repositories on GHCR
For this one, I will just get an anonymous token and list the tags and/or pull the manifest for a single tag from my publicly accessible repository toddysm/python
. This works as expected as shown by the following output:
### Authenticate with anonymous token ### REQUEST GET https://ghcr.io/token?service=ghcr.io&scope=repository:toddysm/python:pull ### RESPONSE HTTP/1.1 200 OK Content-Type: application/json docker-distribution-api-version: registry/2.0 Date: Mon, 12 Feb 2024 18:31:05 GMT Content-Length: 65 X-GitHub-Request-Id: D35A:2D7E60:15F6D:1FCAE:65CA63E9 connection: close { "token": "djE6dG9kZH<redacted>" } ### List the tags for the toddysm/python repository ### REQUEST GET https://ghcr.io/v2/toddysm/python/tags/list Authorization: Bearer djE6dG9kZH<redacted> ### RESPONSE HTTP/1.1 200 OK Content-Type: application/json docker-distribution-api-version: registry/2.0 Date: Mon, 12 Feb 2024 18:33:08 GMT Content-Length: 42 X-GitHub-Request-Id: 36CE:2DA544:CA96:168D1:65CA6463 connection: close { "name": "toddysm/python", "tags": [ "3.10" ] } ### Get the manifest for toddysm/python:3.10 image ### REQUEST GET https://ghcr.io/v2/toddysm/python/manifests/3.10 Authorization: Bearer djE6dG9kZH<redacted> ### RESPONSE HTTP/1.1 200 OK Content-Length: 1370 Content-Type: application/vnd.docker.distribution.manifest.v2+json docker-content-digest: sha256:1d12eaa626fc3387d0e952eb6e7d3ab5f518f02bb75d14a3089ff4bef19970a1 docker-distribution-api-version: registry/2.0 ETag: "sha256:1d12eaa626fc3387d0e952eb6e7d3ab5f518f02bb75d14a3089ff4bef19970a1" Date: Mon, 12 Feb 2024 18:33:19 GMT X-GitHub-Request-Id: 2409:2D16E4:D5F2:1744F:65CA646E connection: close { "schemaVersion": 2, "mediaType": "application/vnd.docker.distribution.manifest.v2+json", "config": { "mediaType": "application/vnd.docker.container.image.v1+json", "size": 6951, "digest": "sha256:083ea201829c5a731ab9a1f43b5b8c4dae317d00a80b6926cdc98c56b824a957" }, "layers": [ ... ] }
Trying to DELETE
the manifest with an anonymous token results in 405 Method Not Allowed error:
### Delete the toddysm/python:3.10 manifest with anonymous token ### REQUEST DELETE https://ghcr.io/v2/toddysm/python/manifests/3.10 Authorization: Bearer djE6dG9kZH<redacted> ### RESPONSE HTTP/1.1 405 Method Not Allowed Content-Type: application/json docker-distribution-api-version: registry/2.0 Date: Mon, 12 Feb 2024 18:37:32 GMT Content-Length: 78 X-GitHub-Request-Id: 5D17:2DA544:CD0D:16D6C:65CA656C connection: close { "errors": [ { "code": "UNSUPPORTED", "message": "The operation is unsupported." } ] }
That is a little bit unexpected for me because I would expect to see 403 Forbidden error. My assumption is that POST
and PUT
have the same behavior – I have not tested those. HEAD
requests work as expected and I get 200 OK as response.
### HEAD request to the toddysm/python:3.10 manifest with anonymous token ### REQUEST HEAD https://ghcr.io/v2/toddysm/python/manifests/3.10 Authorization: Bearer djE6dG9kZHl<redacted> ### RESPONSE HTTP/1.1 200 OK Content-Length: 1370 Content-Type: application/vnd.docker.distribution.manifest.v2+json docker-content-digest: sha256:1d12eaa626fc3387d0e952eb6e7d3ab5f518f02bb75d14a3089ff4bef19970a1 docker-distribution-api-version: registry/2.0 ETag: "sha256:1d12eaa626fc3387d0e952eb6e7d3ab5f518f02bb75d14a3089ff4bef19970a1" Date: Mon, 12 Feb 2024 18:41:03 GMT X-GitHub-Request-Id: 48C5:386761:FCA3:19EB8:65CA663F connection: close
The above behavior is somewhat expected (except the 405 response) – the reason being that I have requested a token with only pull
permissions. Trying to request an anonymous token with broader range of permissions results in 403 Forbidden error:
### Authenticate for pull,push,delete for the toddysm/python repository ### REQUEST GET https://ghcr.io/token?service=ghcr.io&scope=repository:toddysm/python:pull,push,delete ### RESPONSE HTTP/1.1 403 Forbidden Content-Type: application/json docker-distribution-api-version: registry/2.0 Date: Mon, 12 Feb 2024 18:50:29 GMT Content-Length: 86 X-GitHub-Request-Id: 0D82:2D38BB:E6F6:18D9E:65CA6875 connection: close { "errors": [ { "code": "DENIED", "message": "requested access to the resource is denied" } ] }
I like this response because it tells me that I cannot request broader permissions anonymously.
Authentication Flow for Pull, Push, and Delete from a Private Repositories on GHCR
To push and delete images from a repository, I need to authenticate with valid credentials. Same approach I have done to list the _catalog
. Trying to DELETE a manifest with this token, I get the 405 Method Not Allowed error:
### Authenticate with username and password for the toddysm/python repository GET https://ghcr.io/token?service=ghcr.io&scope=repository:toddysm/python:pull,push,delete Authorization: Basic <your_base64_encoded_credentials> ### RESPONSE HTTP/1.1 200 OK Content-Type: application/json docker-distribution-api-version: registry/2.0 Date: Mon, 12 Feb 2024 19:01:43 GMT Content-Length: 69 X-GitHub-Request-Id: 7AA5:2D38BB:EF62:19B79:65CA6B17 connection: close { "token": "Z2hwX<redacted>" } ### Delete a manifest with the auth token ### REQUEST DELETE https://ghcr.io/v2/toddysm/python/manifests/3.10 Authorization: Bearer Z2hwX<redacted> ### RESPONSE HTTP/1.1 405 Method Not Allowed Content-Type: application/json docker-distribution-api-version: registry/2.0 Date: Mon, 12 Feb 2024 18:57:53 GMT Content-Length: 78 X-GitHub-Request-Id: 4CAD:2D681D:F112:19B4E:65CA6A31 connection: close { "errors": [ { "code": "UNSUPPORTED", "message": "The operation is unsupported." } ] }
Now, this is a bit more puzzling. It seems the delete
operation on the manifest is not allowed. Of course, this begs the obvious question: “How do you delete images from the registry?” To better understand the behavior, I tried to delete
a blob as well as put
a new manifest into the repository.
Deleting a blob yields the same result as deleting a manifest – 405 Method Not Allowed:
### Delete a blob with the auth token ### REQUEST DELETE https://ghcr.io/v2/toddysm/python/blobs/1bc163a14ea6a886d1d0f9a9be878b1ffd08a9311e15861137ccd85bb87190f9 Authorization: Bearer Z2hwX<redacted> ### RESPONSE HTTP/1.1 405 Method Not Allowed Content-Type: application/json docker-distribution-api-version: registry/2.0 Date: Mon, 12 Feb 2024 19:50:24 GMT Content-Length: 78 X-GitHub-Request-Id: 26D4:2D332B:1925B:255FA:65CA7680 connection: close { "errors": [ { "code": "UNSUPPORTED", "message": "The operation is unsupported." } ] }
Putting a manifest into the registry with the same token succeeds:
### Put a manifest with the auth token ### REQUEST PUT https://ghcr.io/v2/toddysm/python/manifests/3.11 Authorization: Bearer Z2hwX<redacted> Content-Type: application/vnd.docker.distribution.manifest.v2+json { "schemaVersion": 2, "mediaType": "application/vnd.docker.distribution.manifest.v2+json", "config": { "mediaType": "application/vnd.docker.container.image.v1+json", "size": 6951, "digest": "sha256:083ea201829c5a731ab9a1f43b5b8c4dae317d00a80b6926cdc98c56b824a957" }, "layers": [ ... ] } ### RESPONSE HTTP/1.1 201 Created Content-Type: application/json docker-content-digest: sha256:e7aa768fe7588f909d3463a6d774d182ec967db40eb59c34868ed7d6f5977484 docker-distribution-api-version: registry/2.0 Location: /v2/toddysm/python/manifests/sha256:e7aa768fe7588f909d3463a6d774d182ec967db40eb59c34868ed7d6f5977484 Date: Mon, 12 Feb 2024 19:56:50 GMT Content-Length: 0 X-GitHub-Request-Id: D3C0:2D6593:10052:1C70C:65CA7802 connection: close
There is a known inconsistency in how registries are implementing the delete
capability and my assumption is that GHCR went with the above approach. From authentication and authorization point of view, the above behavior seems functional.
Summary
A couple of things I learned from this experience:
- The authentication experience for GHCR is not so explicit as the Docker Hub one. The scope in the
www-authentication
header cannot be used for crafting the authentication request. - The use of a consistent DNS for both, the registry and the authentication, seems convenience. Also the
realm
in thewww-authentication
header is reliable. - I can rely that the registry will reply with the proper response when I request permissions. This can save some calls to the registry compared to the Docker Hub’s approach.
- As with Docker Hub, GHCR’s responses may be confusing. For example, I am not sure how a client should implement the
delete
capability for GHCR. Also, requesting the right token for the_catalog
is convoluted.
Two things that I noted for further investigation. One, is the delete
behavior for each of the registries I investigate. I will separate that from the authentication investigations to make sure the scope doesn’t increase significantly. Second, is to investigate the capabilities for push other types of content and not only container images.