From a51277ffed10544952db7df51d5604557399e9f5 Mon Sep 17 00:00:00 2001 From: Philipp Wagner Date: Fri, 17 Jan 2025 22:01:37 +0100 Subject: [PATCH] Re-authenticate against OCI registry after 403 error 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. --- src/spec-configuration/httpOCIRegistry.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/spec-configuration/httpOCIRegistry.ts b/src/spec-configuration/httpOCIRegistry.ts index 4d7be0ced..2bebba82e 100644 --- a/src/spec-configuration/httpOCIRegistry.ts +++ b/src/spec-configuration/httpOCIRegistry.ts @@ -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 = {}; } @@ -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'];