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 hint repository: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 as repository:toddysm/python:pull in the www-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 the www-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.

One response to “Authenticating with OCI Registries – GitHub Container Registry (GHCR) Implementation”

  1. B. Stack Avatar
    B. Stack

    Wow, great post! I needed this when checking if I had the latest sha256sum. I dislike how I have to fetch all tags and then loop over them to check on the .config.digest, but ghcr’s design is not your fault! The docker hub one is way easier to use.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Discover more from ToddySM

Subscribe now to keep reading and get access to the full archive.

Continue reading