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
| Instance | Role | URL | Realm |
|---|---|---|---|
| IdP A — “Registry Demo IdP” | fronts the (simulated) private registry | http://127.0.0.1:8180 | CORP.LOCAL |
| IdP B — “Registry Demo Workload Issuer” | stands in for a CI provider (e.g. GitHub Actions) minting workload OIDC ID tokens | http://127.0.0.1:8181 | PARTNER.LOCAL |
What it shows
client_secretauth — a statically-configured client (ci-runner-direct) authenticates with HTTP Basic Auth (client_id:client_secret) and receives a token scoped to itsallowed_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_repositoriesgets pull-only access; a requestedpushaction in the same scope is silently dropped, never granted anonymously regardless of configuration. - Service allowlist — a request for a
servicenot listed in[registry] allowed_servicesis refused with401, 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-actionsnarrow (and can later widen) what a client’s ownallowed_registry_repositoriesgrant 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
ahdapaandahdapactlbinaries in$PATH(orcargo buildfirst)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
-
Starts IdP A (
[registry]enabled,ci-runner-directstatic client withallowed_registry_repositories) and IdP B (workload identity issuer). -
Test 1 —
client_secretauth: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
accessclaim and confirms it grantspull+pushondemo/app, matching the client’s static grant. -
Logs in as
alice(admin) at IdP A and bootstraps anahdapactlsession 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 aci-runner-jwtclient at IdP A viaahdapactl clients create --file <generated.toml>withgrant_types: ["urn:ietf:params:oauth:grant-type:token-exchange"],allowed_token_exchange_audiencesset to the IdP B client’s ID, and[[allowed_registry_repositories]](pattern = "demo/*",actions = ["pull"]) —--filemode is used because this client has no redirect URIs, whichahdapactl’s flag-based--redirect-uriscan’t express. Creates a federation policy mapping*@PARTNER.LOCALto that client (raw curl). -
Logs in as
dianaat 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()). -
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
accessclaim grantspullonly ondemo/app— the JWT resolved toci-runner-jwtvia the federation policy, and that client’s own grant (pull-only) governs the outcome even thoughpushwas also requested in the scope. -
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
accessclaim grantspullonly —pushis silently dropped. -
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. -
Creates a
CI Runner (HBAC-scoped)client viaahdapactl clients create --allowed-registry-repository "demo/*:pull,push,delete", then aclient_secretviaahdapactl clients credentials generate --auth-method client_secret. -
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,deletebefore 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 trueis 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 justpull. - Requests
pullonly — granted, since that’s what the rule covers. ahdapactl hbac update <id> --add-registry-actions pushwidens the rule.- Requests
pull,push— both now granted. - Re-requests
pull,push,delete— still denied outright, sincedeletewas 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-directandci-runner-jwtwould now also need a permitting rule, or they’d be denied despite their ownallowed_registry_repositoriesgrant. 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. - Requests
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
| File | Description |
|---|---|
idpa.toml | IdP A config: [registry] enabled, trusts IdP B, __PORT_A__/__PORT_B__ substituted at runtime |
idpb.toml | IdP B config: workload identity issuer, registration_token = "demo-registry-setup-token" enables POST /register |
clients-idpa.toml | Static OAuth2 clients for IdP A: ci-runner-direct |
users-idpa.toml | Local users for IdP A (alice); password substituted at runtime |
users-idpb.toml | Local 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
- Registry Token Endpoint — full configuration reference.
- Two-IdP federation demo — the underlying federation-policy mechanism this demo’s JWT-passwordless path reuses.