Skip to content

fix(azure): mint Azure Container Registry tokens through the registry's token service - #926

Merged
e6qu merged 3 commits into
mainfrom
fix-acr-oauth2-token-exchange
Aug 15, 2026
Merged

fix(azure): mint Azure Container Registry tokens through the registry's token service#926
e6qu merged 3 commits into
mainfrom
fix-acr-oauth2-token-exchange

Conversation

@e6qu

@e6qu e6qu commented Aug 15, 2026

Copy link
Copy Markdown
Owner

The defect

backends/azure-common/acr_auth.go asked Microsoft Entra for a token scoped to
https://<registry>.azurecr.io/.default and put that raw Entra token on the
registry's /v2/ endpoint as a Bearer. Real Azure Container Registry refuses
this. Microsoft documents it plainly:

To get a Microsoft Entra authentication token scoped for the ACR service, you
must specify https://containerregistry.azure.net/.default. You can't specify
https://registryname.azurecr.io/ as the scope, as Microsoft Entra ID and ACR
don't support registry-specific Microsoft Entra authentication audiences.

The Go SDK agrees — azcontainerregistry hardcodes
defaultAudience = "https://containerregistry.azure.net" for every cloud.

The fix

A token service implementing the documented two-hop exchange: an Entra token for
the ACR service audience → POST /oauth2/exchange for a refresh token → POST /oauth2/token for a scoped access token, which is what goes on /v2/. Tokens
are cached per (service, scope) off the JWT exp with a five-minute margin, and
a 401 forces re-acquisition. There is no fallback to the old path — a fallback
would hide the bug.

Two details differ from what a reasonable reading would assume, both verified
rather than guessed:

  • ACR returns no expires_in. AcrAccessToken in azure-rest-api-specs has
    exactly one member, access_token. Clients read the JWT exp claim. A token
    whose expiry cannot be read is therefore never cached, rather than being given
    an invented TTL.
  • tags/list requires metadata_read, not pull (Azure CLI's own
    RepoAccessTokenPermission), and registry-level scopes carry only *.

Access-token lifetime is undocumented — measured at 20 minutes against a live
registry — so nothing is hardcoded.

The scope has to reach the provider, so core.AuthProvider.GetToken now takes
the repository and actions. AWS and GCP document those arguments as unused,
their credentials being registry-wide. AssembleMultiArchManifest was using an
ARM-audience token behind a comment claiming the exchange was optional; it goes
through the same provider now and the false comment is gone. core/registry.go's
GCP-host special case is generalised to "the provider already minted a Bearer",
which is what had been producing Authorization: Basic Bearer ey… for ACR
metadata fetches.

Coverage

acr_registry_test.go starts the pinned Azure simulator, provisions a registry
through ARM, and performs a real push → tag → tag-list → remove over /v2/,
differing from the cloud only in coordinates. It asserts the presented
credential is not the Entra token it was exchanged for — the defect
expressed as a test. No harness previously set
SOCKERLESS_AZURE_ACR_ENDPOINT; this is the first.

Honest limit: the pinned simulator (v0.9.2) still accepts any token on the ACR
data plane, so the test proves the correct flow runs but cannot fail via the
simulator's own enforcement. The enforcing simulator is on sockerless-cloud
main and is not in a release yet.

Pins

Bumped to v0.9.2, verified complete before pinning (30 assets, all three
multi-architecture indexes) — a tag can exist while its artifacts do not.
v0.10.0's release PR is still open. Every pin moved together: tests/go.mod
(pseudo-version off release commit 723736a8a233), 23 Dockerfile
SOCKERLESS_CLOUD_VERSION defaults, deploy/compose.build.yaml, and the
live-tests workflow ref.

Dependencies

30 drifted AWS SDK modules upgraded and verified against real wire shapes, not a
compile: Lambda and ECS integration suites both green.

Defects found while verifying, fixed here

  • BUG-2941 — the ECS and Lambda backends launched the simulator without
    SIM_DNS_PORT, so its Route 53 listener grabbed the default 5353 that
    Chrome's helper owns and the simulator panicked before serving.
  • BUG-2947 — six of seven harness image builds omitted --load, so buildx
    left the image in the build cache and the workload could never pull it. This
    presented as five Lambda tests timing out at 90s on a callback that never
    came.

Filed, not fixed

  • BUG-2943 — the Cloud Run and Cloud Functions harnesses push to Artifact
    Registry with no docker login, and neither creates the target repository
    through the control plane. Correct against the current pinned simulator,
    wrong against the enforcing one, which is unreleased — so it can be neither
    proven necessary nor regression-checked yet.
  • BUG-2945 — the Azure simulator serves no /v2/_catalog, so the round trip
    reads tags/list directly instead of core.OCIListImages.
  • BUG-2946NewARAuthProvider reads config.EndpointURL while
    OverlayRegistryHost reads SOCKERLESS_GCP_AR_ENDPOINT; they agree only
    because the harnesses set both.

Adrian Mârza added 3 commits August 15, 2026 19:33
…'s token service

An Azure Container Registry does not accept a Microsoft Entra token on its
Docker Registry HTTP API v2 surface, and sockerless was presenting one. The
auth provider asked Microsoft Entra for `https://<registry>/.default` — an
audience Microsoft Entra does not issue, because Azure Container Registry has
no per-registry audience — and put the result straight on /v2/ as a Bearer;
the multi-architecture index writer did the same with an Azure Resource
Manager token, beside a comment asserting the token exchange was only needed
by tools wanting long-lived credentials.

The documented flow now runs: a Microsoft Entra token for the container
registry service audience `https://containerregistry.azure.net/.default`,
exchanged at POST /oauth2/exchange for an Azure Container Registry refresh
token, traded at POST /oauth2/token for an access token scoped to the access
the operation needs, and that access token is the Bearer the data plane
honours. Tokens are cached until the `exp` claim of the JWT the registry
issued, and re-acquired when a registry answers 401.
`core.AuthProvider.GetToken` carries the repository and Docker Registry HTTP
API v2 actions; the AWS and Google Cloud providers document them as unused
because their credentials are registry-wide.

Around it, the registry endpoint coordinate became one thing: push, image
listing, the multi-architecture index writer, the image-metadata fetch and the
Artifact Registry tag probe all resolve it through `core.OCIRegistryBaseURL`
and keep the Host header naming the registry through `core.SetOCIHost`. A
failure to mint a registry credential surfaces instead of being retried
anonymously in pull, push, build and listing.

Covered end to end against the Microsoft Azure simulator in
`backends/azure-common`: a registry provisioned through Azure Resource
Manager, an image pushed over /v2/, a second tag added, the repository's tags
read back with a metadata_read-scoped token, both tags removed, and an
assertion that the credential presented is not the Microsoft Entra token it
was exchanged for.

Also in this change:

- The simulators are pinned at the sockerless-cloud v0.9.2 release, verified
  complete: tests/go.mod at release commit 723736a8a233, twenty-three harness
  ARG SOCKERLESS_CLOUD_VERSION defaults at the same commit, and the git build
  context and AWS Lambda live-test checkout at the v0.9.2 tag.
- The AWS SDK modules flagged by the dependency freshness check are upgraded
  across backends/{aws-common,ecs,lambda} and tests, verified by the Amazon
  ECS and AWS Lambda integration suites against the simulator.
- The Amazon ECS and AWS Lambda harnesses reserve their own Amazon Route 53
  resolver port, and every harness image build passes --load so its result
  reaches the container runtime's image store.

BUGS 2938-2942 and 2947 closed; 2943-2946 filed.
… simulator

The Azure Container Registry round trip built the simulator with a relative
`-o` destination while running `go build` in the tests module directory, so the
path resolved against that directory rather than the test's working directory
and wrote the binary one level above the repository. The test then started the
simulator from the path it meant all along, found nothing, and failed with
"fork/exec ../../tests/.build/simulator-azure: no such file or directory".

It passed locally only because `make install-simulators` had already put a real
binary where the test looked, so the misplaced build output went unnoticed; CI's
core job builds no simulators, so there it failed every time. Every other
integration test in the repository already resolves the root with
`filepath.Abs`, which is the convention this one missed.

Verified by removing the prebuilt binary and reproducing the exact CI failure,
then confirming the fix builds into the repository and the round trip passes
from a clean tree.
@e6qu
e6qu merged commit c7f0bec into main Aug 15, 2026
31 checks passed
@e6qu
e6qu deleted the fix-acr-oauth2-token-exchange branch August 15, 2026 17:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant