Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Demo: registry token endpoint

Location: contrib/demo/registry/

Starts two ahdapa instances on loopback ports 8180 and 8181 and demonstrates the OCI Distribution v2 registry token endpoint (GET /registry/token): a private container registry delegates authentication to ahdapa, the same way Docker Hub’s GitHub Actions OIDC connections work for public registries.

Topology

InstanceRoleURLRealm
IdP A — “Registry Demo IdP”fronts the (simulated) private registryhttp://127.0.0.1:8180CORP.LOCAL
IdP B — “Registry Demo Workload Issuer”stands in for a CI provider (e.g. GitHub Actions) minting workload OIDC ID tokenshttp://127.0.0.1:8181PARTNER.LOCAL

What it shows

  • client_secret auth — a statically-configured client (ci-runner-direct) authenticates with HTTP Basic Auth (client_id:client_secret) and receives a token scoped to its allowed_registry_repositories.
  • JWT-passwordless auth — an ID token minted at IdP B is presented as the Basic Auth password, resolved through a federation policy to a local service client (ci-runner-jwt), and scoped to that client’s own grant — no long-lived registry secret involved, reusing the same federation-policy mechanism the two-IdP federation demo exercises for /federation/token.
  • Anonymous pull — an unauthenticated request against a repository matching anonymous_pull_repositories gets pull-only access; a requested push action in the same scope is silently dropped, never granted anonymously regardless of configuration.
  • Service allowlist — a request for a service not listed in [registry] allowed_services is refused with 401, regardless of credentials, closing the confused-deputy hole where an attacker-controlled registry hostname could otherwise get a token minted for it.
  • HBAC registry axis — once an HBAC rule exists, ahdapactl hbac create/hbac update --registry-repositories/--registry-actions narrow (and can later widen) what a client’s own allowed_registry_repositories grant would otherwise allow. The check is all-or-nothing per repository: if any client-grant-narrowed requested action isn’t covered by the rule, the whole request is denied — it never falls back to whatever smaller subset the rule does cover.

Admin provisioning (client creation, credential generation, HBAC rules) is done via ahdapactl, not raw curl against the admin API.

Prerequisites

  • ahdapa and ahdapactl binaries in $PATH (or cargo build first)
  • python3 (JSON parsing and PKCE/JWT decoding in the setup script)
  • curl
  • ports 8180 and 8181 free

Running

# Non-interactive (automated test, exits with pass/fail):
contrib/demo/registry/run.sh

# Interactive (run tests, then keep servers up for manual exploration):
contrib/demo/registry/run.sh --interactive

What the script does

  1. Starts IdP A ([registry] enabled, ci-runner-direct static client with allowed_registry_repositories) and IdP B (workload identity issuer).

  2. Test 1 — client_secret auth:

    curl -u ci-runner-direct:ci-runner-secret \
      "http://127.0.0.1:8180/registry/token?service=registry.example.com&scope=repository:demo/app:pull,push"
    

    Decodes the returned JWT’s access claim and confirms it grants pull+push on demo/app, matching the client’s static grant.

  3. Logs in as alice (admin) at IdP A and bootstraps an ahdapactl session from that cookie. Registers a temporary public client at IdP B (raw curl — this simulates a third-party workload self-registering, not an admin operation). Creates a ci-runner-jwt client at IdP A via ahdapactl clients create --file <generated.toml> with grant_types: ["urn:ietf:params:oauth:grant-type:token-exchange"], allowed_token_exchange_audiences set to the IdP B client’s ID, and [[allowed_registry_repositories]] (pattern = "demo/*", actions = ["pull"]) — --file mode is used because this client has no redirect URIs, which ahdapactl’s flag-based --redirect-uris can’t express. Creates a federation policy mapping *@PARTNER.LOCAL to that client (raw curl).

  4. Logs in as diana at IdP B and drives a standard authorization_code + PKCE flow to mint an ID token — simulating what a real CI provider hands a pipeline directly (e.g. GitHub Actions’ getIDToken()).

  5. Test 2 — JWT-passwordless auth:

    curl -u "<ci-runner-jwt-client-id>:<id_token>" \
      "http://127.0.0.1:8180/registry/token?service=registry.example.com&scope=repository:demo/app:pull,push"
    

    Confirms the resulting access claim grants pull only on demo/app — the JWT resolved to ci-runner-jwt via the federation policy, and that client’s own grant (pull-only) governs the outcome even though push was also requested in the scope.

  6. Test 3 — anonymous pull:

    curl "http://127.0.0.1:8180/registry/token?service=registry.example.com&scope=repository:demo/public:pull,push"
    

    Confirms the access claim grants pull only — push is silently dropped.

  7. Test 4 — service allowlist:

    curl -o /dev/null -w '%{http_code}' \
      "http://127.0.0.1:8180/registry/token?service=evil.example.com&scope=repository:demo/app:pull"
    

    Confirms 401.

  8. Creates a CI Runner (HBAC-scoped) client via ahdapactl clients create --allowed-registry-repository "demo/*:pull,push,delete", then a client_secret via ahdapactl clients credentials generate --auth-method client_secret.

  9. Test 5 — HBAC registry axis, run last because it creates IdP A’s first HBAC rule, which changes global behavior (see the callout below):

    • Requests pull,push,delete before any HBAC rule exists — the client’s own grant governs, all three are returned.
    • ahdapactl hbac create --clients <id> --registry-repositories "demo/*" --registry-actions pull --mfa-bypass true--mfa-bypass true is required because the registry endpoint is a machine-to-machine flow that never satisfies interactive MFA, and a freshly created rule defaults to requiring it.
    • Re-requests pull,push,delete — denied outright (access: []). The HBAC check is all-or-nothing per repository: every client-grant-narrowed requested action must individually clear the rule, or nothing is granted at all — it does not fall back to just pull.
    • Requests pull only — granted, since that’s what the rule covers.
    • ahdapactl hbac update <id> --add-registry-actions push widens the rule.
    • Requests pull,push — both now granted.
    • Re-requests pull,push,delete — still denied outright, since delete was never added to the rule.

    Once this step runs, IdP A has an HBAC rule, so every subsequent /registry/token (and /token) request on it is checked against HBAC — ci-runner-direct and ci-runner-jwt would now also need a permitting rule, or they’d be denied despite their own allowed_registry_repositories grant. This is inherent to how HBAC gating works once any rule exists (see HBAC), not a demo artifact — it’s why this test runs after, not before, Tests 1–4.

Example output

Test 1 — client_secret auth against /registry/token
  access claim: [{"type": "repository", "name": "demo/app", "actions": ["pull", "push"]}]
  ✓ client_secret auth granted pull+push on demo/app

Setting up JWT-passwordless workload identity...
  ✓ Logged in as alice at IdP A
  ✓ ahdapactl session bootstrapped
  ✓ Registered workload test client at IdP B (483d836f-391c-460f-bf2c-50b03cf5daa6)
  ✓ Created ci-runner-jwt client at IdP A via ahdapactl (18650549-eb2c-44a2-8263-a19a2a549b3d)
  ✓ Created federation policy (*@PARTNER.LOCAL → 18650549-eb2c-44a2-8263-a19a2a549b3d)
  ✓ Logged in as diana at IdP B
  ✓ Minted ID token at IdP B (658 chars)

Test 2 — JWT-passwordless auth against /registry/token
  access claim: [{"type": "repository", "name": "demo/app", "actions": ["pull"]}]
  ✓ JWT-passwordless auth resolved via federation policy, granted pull only

Test 3 — anonymous pull against a public repository
  access claim: [{"type": "repository", "name": "demo/public", "actions": ["pull"]}]
  ✓ anonymous request granted pull only — push was never included

Test 4 — unlisted service is refused
  ✓ unlisted service refused with 401

Test 5 — HBAC registry axis narrows (then widens) a client's own grant
  Creating client 'CI Runner (HBAC-scoped)' via ahdapactl (grant: demo/*: pull,push,delete)...
  ✓ Client created: b2d50a10-13b5-40c8-b7ae-489eb0d37659

  Generating a client_secret for it (ahdapactl clients credentials generate)...
  ✓ client_secret generated

  Requesting pull+push+delete before any HBAC rule exists on IdP A...
  access claim: [{"type": "repository", "name": "demo/app", "actions": ["pull", "push", "delete"]}]
  ✓ no HBAC rules yet — client's own grant (pull+push+delete) governs

  Creating the first HBAC rule on IdP A (ahdapactl hbac create), scoped to
  this client, permitting pull only. --mfa-bypass true is required here:
  the registry token endpoint is a machine-to-machine flow that never
  satisfies interactive MFA, and a rule defaults to requiring it.
created HBAC rule: id=99ffdf8f907e522d0000000000000001 name=registry-narrow-access
  ✓ HBAC rule created: 99ffdf8f907e522d0000000000000001

  Re-requesting pull+push+delete — HBAC only covers pull, so the whole
  request is denied (not narrowed to just pull)...
  access claim: []
  ✓ denied outright — HBAC requires every requested action to be covered

  Requesting pull only — within what the HBAC rule actually covers...
  access claim: [{"type": "repository", "name": "demo/app", "actions": ["pull"]}]
  ✓ HBAC permits pull, below the client's own broader grant

  Widening the rule (ahdapactl hbac update --add-registry-actions push)...
updated HBAC rule 99ffdf8f907e522d0000000000000001

  Requesting pull+push — both now covered by the widened rule...
  access claim: [{"type": "repository", "name": "demo/app", "actions": ["pull", "push"]}]
  ✓ HBAC update widened access to pull+push

  Requesting pull+push+delete once more — delete is still excluded, so
  the whole request is still denied even after widening...
  access claim: []
  ✓ still denied outright — the rule never granted delete, regardless
    of the client's own broader grant

  NOTE: IdP A now has an HBAC rule, so every /registry/token (and /token)
        request on it is checked against HBAC from here on — ci-runner-direct
        and ci-runner-jwt would now need their own permitting rule too, or
        they'd be denied despite their own allowed_registry_repositories grant.
        This is why this test runs last.

All registry token endpoint checks passed.

In a real deployment, docker login/podman login would send the CI provider’s OIDC JWT as the Basic Auth password directly, and the private registry itself (configured to trust ahdapa’s signing key) would issue the WWW-Authenticate: Bearer realm="…" challenge that points docker at /registry/token in the first place — ahdapa never needs to know about the registry’s own storage or manifest handling, only its auth delegation.

Configuration notes

FileDescription
idpa.tomlIdP A config: [registry] enabled, trusts IdP B, __PORT_A__/__PORT_B__ substituted at runtime
idpb.tomlIdP B config: workload identity issuer, registration_token = "demo-registry-setup-token" enables POST /register
clients-idpa.tomlStatic OAuth2 clients for IdP A: ci-runner-direct
users-idpa.tomlLocal users for IdP A (alice); password substituted at runtime
users-idpb.tomlLocal users for IdP B (diana); password substituted at runtime

The [registry] stanza in IdP A’s config:

[registry]
enabled = true
allowed_services = ["registry.example.com"]
anonymous_pull_repositories = ["demo/public"]

IdP A trusts ID tokens issued by IdP B (reused for the JWT-passwordless path):

[federation]
trusted_issuers = ["http://127.0.0.1:8181"]

See also