Skip to content

Commit

Permalink
Re-authenticate against OCI registry after 403 error
Browse files Browse the repository at this point in the history
Writing to an OCI registry, as done with `devcontainer features publish`
requires authentication against the registry with the `push` OAuth
scope. Currently, devcontainer CLI only authenticates or
re-authenticates if the registry returns a 401 error (invalid_token).
But some registries, notably the IBM Container Registry (icr.io) may
also return a 403 error (insufficient_scope) in case of a request being
authenticated, but without sufficient scopes (i.e., the token was only
valid for the `pull` scope, but `push,pull` was required for write
access).

Update the code to attempt to re-authenticate in case of a 403 error,
just like it's done for a 401 error. The server does supply the correct
scope in its `WWW-Authenticate` header and subsequent requests will then
work as expected.

See also [RFC 6750, Section 3.1 (Error Codes)](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1)
for a standards reference.

This improvement makes `devcontainer features publish` work with IBM
Cloud Container Registry.

---

Test:

```
$ devcontainer.js features publish --registry icr.io -n my-ns/features ~/my-feature`
```

HTTP trace (abbreviated) *before* this change:

```
-> POST https://icr.io/v2/my-ns/features/my-feature/tags/list
-> 401 Unauthorized
   www-authenticate: Bearer realm="https://icr.io/oauth/token",service="registry",scope="repository:my-ns/features/my-feature:pull"

-> POST https://icr.io/oauth/token
   client_id=devcontainer&grant_type=refresh_token&service=registry&scope=repository%3Amy-ns%2Ffeatures%2Fmy-feature%3Apull&refresh_token=...
<- 200 OK

-> POST https://icr.io/v2/my-ns/features/my-feature/blobs/uploads/
   authorization: Bearer
<- 403 Forbidden:
   www-authenticate: Bearer realm="https://icr.io/oauth/token",service="registry",scope="repository:my-ns/features/my-feature:pull,push",error="insufficient_scope"
```

HTTP trace (abbreviated) before *after* change:

```
-> POST https://icr.io/v2/my-ns/features/my-feature/tags/list
-> 401 Unauthorized
   www-authenticate: Bearer realm="https://icr.io/oauth/token",service="registry",scope="repository:my-ns/features/my-feature:pull"

-> POST https://icr.io/oauth/token
   client_id=devcontainer&grant_type=refresh_token&service=registry&scope=repository%3Amy-ns%2Ffeatures%2Fmy-feature%3Apull&refresh_token=...
<- 200 OK

-> POST https://icr.io/v2/my-ns/features/my-feature/blobs/uploads/
   authorization: Bearer
<- 403 Forbidden:
   www-authenticate: Bearer realm="https://icr.io/oauth/token",service="registry",scope="repository:my-ns/features/my-feature:pull,push",error="insufficient_scope"

-> POST https://icr.io/oauth/token
   client_id=devcontainer&grant_type=refresh_token&service=registry&scope=repository%3Amy-ns%2Ffeatures%2Fmy-feature%3Apull%2Cpush&refresh_token=...
<- 200 OK
```

Note the second auth request after the 403 response.
  • Loading branch information
imphil authored and chrmarti committed Jan 22, 2025
1 parent e0598db commit 9291203
Showing 1 changed file with 5 additions and 5 deletions.
10 changes: 5 additions & 5 deletions src/spec-configuration/httpOCIRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const scopeRegex = /scope="([^"]+)"/;

// https://docs.docker.com/registry/spec/auth/token/#how-to-authenticate
export async function requestEnsureAuthenticated(params: CommonParams, httpOptions: { type: string; url: string; headers: HEADERS; data?: Buffer }, ociRef: OCIRef | OCICollectionRef) {
// If needed, Initialize the Authorization header cache.
// If needed, Initialize the Authorization header cache.
if (!params.cachedAuthHeader) {
params.cachedAuthHeader = {};
}
Expand All @@ -54,14 +54,14 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio

const initialAttemptRes = await requestResolveHeaders(httpOptions, output);

// For anything except a 401 response
// Simply return the original response to the caller.
if (initialAttemptRes.statusCode !== 401) {
// For anything except a 401 (invalid/no token) or 403 (insufficient scope)
// response simply return the original response to the caller.
if (initialAttemptRes.statusCode !== 401 && initialAttemptRes.statusCode !== 403) {
output.write(`[httpOci] ${initialAttemptRes.statusCode} (${maybeCachedAuthHeader ? 'Cached' : 'NoAuth'}): ${httpOptions.url}`, LogLevel.Trace);
return initialAttemptRes;
}

// -- 'responseAttempt' status code was 401 at this point.
// -- 'responseAttempt' status code was 401 or 403 at this point.

// Attempt to authenticate via WWW-Authenticate Header.
const wwwAuthenticate = initialAttemptRes.resHeaders['WWW-Authenticate'] || initialAttemptRes.resHeaders['www-authenticate'];
Expand Down

0 comments on commit 9291203

Please sign in to comment.