56 Commits
Author SHA1 Message Date
Evan JarrettandClaude Opus 5 5aa13abdc2 auth: make anonymous pull work, and let the hold decide it
a7569a7 added credential-less pulls of public images. Three things about it
were wrong, all of them in how the appview handled the decision that belongs
to the hold.

**Scope handling was all-or-nothing.** IsPullOnlyScope required every
requested action to already be "pull", but clients routinely ask for more
than the operation needs — pull,push is common for a plain read, and some
ask for pull,push,delete up front. Those were rejected and challenged,
leaving a credential-less client no way to pull even a public image, which
is the entire feature. NarrowToPullOnly drops the write actions and issues a
token carrying "pull" and nothing else. Granting a subset is what the
distribution token spec expects. The allowlist property is preserved: "pull"
is the only action that survives, and "*" is deliberately not expanded into
it, since a wildcard request is not evidence the caller wants a read.

**The appview-side read gate was inert.** checkReadAccess passed p.ctx.DID,
the DID of the repository *owner*, not the requester. Any non-empty DID
satisfies a private hold's check, and the owner's is never empty, so it asked
"may the owner read their own hold", answered yes, and admitted everyone.
Worse, CheckReadAccessWithCaptain admitted any authenticated DID to a private
hold at all, on an explicitly-MVP assumption that holding a DID was close
enough to being a sailor. Every doc says otherwise (docs/hold.md:109 "Crew
with blob:read", CLAUDE.md:140, docs/BYOS.md:280) and so does the hold
(ValidateBlobReadAccess: owner, or crew carrying blob:read/blob:write). It
now takes isCrew and requires owner-or-crew, and callers only pay for the
crew lookup when it can change the answer — a public hold or an anonymous
caller is decided by the captain record alone. Nothing here loosens access;
it brings the local gate into agreement with the authority.

**Denials could not reach the client.** distribution's blobHandler.GetBlob
maps everything except ErrBlobUnknown to ErrorCodeUnknown, so a 401 raised
in the blob store left as a 500 — misreporting an auth failure as a server
fault, and giving BearerChallenge no 401 to attach WWW-Authenticate to, so
Docker was told "server error" instead of being prompted for credentials.
Clients that retry 5xx looped: 4.1s per case in the matrix, now 0.01s. The
check moves to Repository(), where an errcode.Error is passed through
verbatim by the registry app — the same mechanism a7569a7 used for
NAME_UNKNOWN. It fails open on a lookup error, since the hold is the
authority and a transient failure should not break public pulls.

Removes auth.allow_anonymous_pull. It could only ever withhold — captain.Public
is what grants — so it was a second flag for a decision the hold already owns,
and gating it appview-side was never the intent. Layer bytes 307 straight to
S3, so the appview is not even in the path whose cost might have justified an
operator-side lever.

Tests: TestAuthMatrix only ever ran against a public hold, and its pull cases
never fetched a layer — crane.Pull is lazy and img.Digest() needs only the
manifest, which ATCR serves from the user's PDS where it is world-readable, so
no pull row in the matrix touched blob authorization at all. Pulls now
materialize layer bytes, and testharness.WithPrivateHold plus
TestAuthMatrixPrivateHold cover public:false + allow_all_crew:true — the
production shape, where anyone with an account pulls and pushes and anonymous
gets nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:43:32 -05:00
Evan JarrettandClaude Opus 5 a7569a7717 registry: allow anonymous pull of public images
Credential-less pulls of public images. /auth/token issues a pull-only
token with an empty subject when no Basic auth is present; the
destination hold still enforces captain.Public, and push or delete always
challenges.

  - token.IsPullOnlyScope and AuthMethodAnonymous;
    Handler.issueAnonymousToken skips the authorizer gate and the
    service-auth pre-mint, since there is no identity to reconcile and no
    AppView-to-hold service token to bind. The token is still stamped
    with the resolved registry domain, so anonymous pull works on
    secondary front doors whose access controller demands their own
    audience.
  - auth.allow_anonymous_pull (default true) turns it fully off, restoring
    the previous always-challenge behavior. Mirrored into the deploy
    template, since the default means existing deploys pick this up.
  - RegistryContext.Anonymous is plumbed from the middleware.
  - ProxyBlobStore sends no Authorization header when the service token is
    empty, and returns 401 rather than 403 for anonymous denials so Docker
    prompts for credentials, including when a stale captain cache lets the
    request through and the hold says private.
  - BearerChallenge wraps the /v2/ subtree so a 401 raised deep in the
    stack via errcode.ServeJSON still carries WWW-Authenticate.
    Distribution's own scoped challenges are left alone.

IsPullOnlyScope allowlists the pull action instead of denylisting push and
delete. Distribution's actionSet.contains treats "*" as *every* action, so
a scope of `repository:victim/img:*` names neither denied string and would
have handed an unauthenticated caller a token valid for push and delete on
someone else's repository — clearing the authgate entirely, since anonymous
tokens deliberately skip it. Writes would still have failed further down
(no PDS credential), but the gate itself was bypassable. Now every
requested action must be exactly "pull". Covered by new claims tests.

Unresolvable identities return NAME_UNKNOWN instead of a bare error that
distribution renders as 500. This path was previously unreachable without
credentials; anonymous pull opens it to the internet, and a 5xx on
arbitrary input both misreports a bad request as a server fault and sends
clients that retry 5xx into a retry loop. That loop was real: in the auth
matrix, regclient spent 83s on a single case before this fix, and the
suite now runs in 5s.

Stat preserves an authorization verdict from getPresignedURL rather than
flattening it to ErrBlobUnknown. Distribution calls Stat before ServeBlob
on GET and HEAD, so without this an anonymous pull from a private hold
answered 404 and BearerChallenge had no 401 to annotate — the 401 path
above could never actually reach a client.

The auth matrix is updated to match: anonymous pull of the seeded public
repo now succeeds, anonymous push is denied against a real identity's
namespace (rather than an unresolvable one, which was testing name
resolution rather than authorization), and a new case pins the
NAME_UNKNOWN behavior for an unknown identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:14:58 -05:00
Evan JarrettandClaude Opus 5 9d4ad84a3e auth: surface read-only app passwords as 403, not 503
A read-only app password authenticates fine via createSession but cannot
call com.atproto.server.getServiceAuth, which is privileged — the PDS
answers 403 InsufficientScope. That fell through to the generic non-200
path and became a 503, which invites the client to retry a request that
can never succeed, with no indication of what is actually wrong.

Classify it with a sentinel error and map it to a 403 at /auth/token,
carrying text that names the fix: use a full-access app password.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 20:52:59 -05:00
Evan JarrettandClaude Opus 5 6e426dc695 auth: let over-quota users delete by granting the non-push subset
The quota gate ran on any scope containing "push" and denied the entire
token request, so "quota exceeded ... Delete images to free space" named
a remedy the gate itself blocked: docker and crane both request
pull,push,delete for a manifest delete, and manifest DELETE is
bearer-only, so there was no path left to free space.

When the request also asks for delete, drop push from the repository
entries and issue the reduced token instead of denying. A plain
pull,push is still denied so the quota message reaches the client that
needs to see it; granting a pushless token there would turn a clear
error into an opaque 401 on the first blob upload.

The narrowing happens in place on the access slice the handler hands to
the issuer, so document that on token.Authorizer along with the ordering
the gate goroutine depends on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:49:44 -05:00
Evan JarrettandClaude Opus 5 b25aee336b auth: serve the OAuth2 POST form at /auth/token
The route was registered GET-only, so containerd and Docker, which try the
OAuth2 POST endpoint first whenever they hold a secret, ate a 405 and retried
on the GET form. Every authenticated pull paid two auth round trips, and in the
production logs the POST share of token traffic grew from 0.5% to 38% over six
weeks as more clients pulled from k8s with basic-auth imagePullSecrets.

Serve both specs on the same path. After credentials and scope are extracted
the two paths are identical, so this is an extraction branch plus a form-shaped
error writer.

Only grant_type=password is supported and no refresh token is issued: the
registry JWT's lifetime is pinned to the AppView<->hold service-auth, so a
refresh token would be a fourth long-lived credential with its own storage and
revocation. Clients handle its absence by continuing to use the credential they
already hold.

The refresh grant is refused with 401 rather than the 400 that RFC 6749 5.2
prescribes. containerd sends that grant only when it has no username, which is
the same condition that disables its 405 fallback, so a 400 would hard-fail
those clients. 401 is on its retry list and routes them to the GET form, where
a device secret authenticates off the password alone. That shape previously had
no working path at all.

resolveService now takes the requested service as an argument, since it arrives
in the query string on GET and in the form body on POST.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 22:19:10 -05:00
Evan JarrettandClaude Opus 5 2719428071 appview: give each registry domain its own JWT service name
An AppView can front several registry domains that all reach the same
backend (seamark.dev serving buoy.cr, seamark.cr, and soon atcr.io).
Distribution's token access controller holds `service` as a single string
and uses it twice: as the value advertised in the WWW-Authenticate
challenge, and as the sole accepted JWT audience. So it announced one
domain's name on every domain, and honoured one domain's tokens
everywhere. A push to seamark.cr was challenged with service="buoy.cr".

Both uses sit inside Authorized, which already has the request, but the
value is fixed at construction and reachable through no hook — autoredirect
only templates the realm. So register an "atcr-token" controller that
builds one upstream controller per domain and dispatches on r.Host. Each
front door now advertises its own name and demands its own audience. All
signature, certificate and claim verification stays in upstream code; this
only routes.

The token handler stops discarding ?service= and stamps the audience with
the front door the client used, allowlist-checked against the configured
domains so the value stays server-determined despite arriving from the
client. It has to come from the query param because the realm lives on the
UI host, where r.Host names no registry domain.

This is token hygiene and spec conformance, not a privilege boundary: every
domain fronts the same backend, so a client can obtain a token for any of
them just by handshaking there. What it buys is a truthful challenge and
the decoupling needed to later split a domain onto its own AppView.

Also unify the domain list. DomainRoutingMiddleware keyed its map on the
raw config while matching a port-stripped host, so a domain configured with
a port could never match its own requests. It now shares the normalized
cfg.Auth.Services, so routing and authorization agree on one set of names.
cfg.Auth.ServiceName was an exact alias for Services[0] and is replaced by
PrimaryService(), which also removes an empty-slice index.

Rollout: the audience for seamark.cr and bouy.cr changes, so a token minted
just before the restart draws one 401 and Docker re-handshakes into a valid
one. buoy.cr is unchanged (it stays primary), and atcr.io keeps the service
name it already has today. The challenge and the accepted audience come
from the same delegate, so the retry converges by construction. Deploy as a
single flip, not a canary: an old instance ignores ?service= and would keep
minting the primary audience while a new one rejects it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:40:55 -05:00
Evan JarrettandClaude Fable 5 37bab324d7 fix OAuth refresh-token burn on client cancellation causing sign-outs
When a Docker client canceled a slow /auth/token request mid-refresh, the
token-refresh POST was aborted client-side but completed on the PDS, which
rotated the refresh token. The rotated token was never received or persisted,
so the next refresh replayed the consumed token, got invalid_grant, and the
session (OAuth + UI) was deleted, signing the user out everywhere.

- Detach refresh POSTs from the inbound request context via a per-session
  RoundTripper (WithoutCancel + 30s cap); once a refresh starts it completes
- Persist session updates (rotated tokens, DPoP nonces) on a detached context
- Gate session deletion on IsSessionInvalidError: cancellation, timeouts, and
  transport errors no longer delete sessions; genuine invalid_grant still does
- Add phase timing to /auth/token and per-DID lock wait warnings to attribute
  the ~14s pre-refresh stalls that push requests past Docker's deadline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 13:38:45 -05:00
Evan Jarrett 038993c814 fix concurrency test 2026-05-16 12:18:06 -05:00
Evan Jarrett 902fba4553 convert alert to modal. go fix the codebase 2026-05-16 11:59:47 -05:00
Evan Jarrett a0cc862798 lots of new work for authenticating between appview -> hold. fixed quota handling, improve integration tests for round-trip push/pull/auth/quota checking 2026-05-11 09:20:55 -05:00
Evan Jarrett 25628dad2c update the login page 2026-04-11 21:01:31 -05:00
Evan Jarrett 9033d74a19 fix validation on dids with hyphens 2026-04-07 22:26:21 -05:00
Evan Jarrett a68477033a use hyphens as the encode for dids 2026-04-07 21:47:51 -05:00
Evan Jarrett 21b6f6301a allow dids on docker login 2026-04-07 21:32:51 -05:00
Evan Jarrett f20170f595 digest page improvements 2026-03-29 13:01:40 -07:00
Evan Jarrett 6b87539ef8 update scanner, fix tests, fix dockerfile, move keys to db instead of flat files for appview 2026-02-16 21:04:40 -06:00
Evan Jarrett 834bb8d36c libsql instead of sqlite for turso/bunnydb replicated sqlite 2026-02-05 20:43:04 -06:00
Evan Jarrett 51f6917444 add log shipper begin envvar cleanup 2026-01-08 22:52:32 -06:00
Evan Jarrett 9704fe091d use chi/render to simplify returned json 2026-01-06 22:47:21 -06:00
Evan Jarrett af815fbc7d use for range and wg.Go 2026-01-04 22:39:48 -06:00
Evan Jarrett efef46b15a various linting fixes 2026-01-04 22:02:01 -06:00
Evan Jarrett fbcaf56fce fixup unused functions/vars 2026-01-04 21:16:02 -06:00
Evan Jarrett a7175f9e3e interface{} -> any 2026-01-04 21:10:29 -06:00
Evan Jarrett e6bd4c122e fix sql migration bug. add better error logs for auth failures. fix showing incorrect pull commands with helm charts 2026-01-03 17:26:25 -06:00
Evan Jarrett 7f2d780b0a move packages out of token that are not related to docker jwt token 2025-12-29 16:57:14 -06:00
Evan Jarrett 2a60a47fd5 fix issues pulling other users images. fix labels taking priority over annotations. fix various auth errors 2025-12-23 16:20:52 -06:00
Evan Jarrett 509a1c0306 some lexicon json cleanup. code formatting 2025-12-20 10:46:40 -06:00
Evan Jarrett 4063544cdf cleanup view around attestations. credential helper self upgrades. better oauth support 2025-12-18 09:33:31 -06:00
Evan Jarrett 5f1eb05a96 try and provide more helpful reponses when oauth expires and when pushing manifest lists 2025-11-25 09:25:38 -06:00
Evan Jarrett 66037c332e locks locks locks locks 2025-11-24 22:49:17 -06:00
Evan Jarrett fb7ddd0d53 try and create a cache for layer pushing again 2025-11-24 13:25:24 -06:00
Evan Jarrett ecf84ed8bc type-ahead login api. fix app-passwords not working without oauth 2025-11-09 21:57:28 -06:00
Evan Jarrett 628f8b7c62 try and trace oauth failures 2025-11-09 13:07:35 -06:00
Evan Jarrett 15d3684cf6 try and fix bad oauth cache 2025-11-08 20:47:57 -06:00
Evan Jarrett 6ef2aaf709 more test coverage. clean up docs 2025-10-28 20:39:57 -05:00
Evan Jarrett b0799cd94d unit tests 2025-10-28 17:40:11 -05:00
Evan Jarrett 4cfe6f221d create identity resolver to reduce duplicate lookups 2025-10-26 23:08:03 -05:00
Evan Jarrett 0b22082f89 lower cached plc hits to 8 hours 2025-10-26 22:50:42 -05:00
Evan Jarrett c831d3f735 auth/token needs GET 2025-10-25 19:42:54 -05:00
Evan Jarrett 35ba417a96 more slog 2025-10-25 10:14:19 -05:00
Evan Jarrett f75d9ceafb big scary refactor. sync enable_bluesky_posts with captain record. implement oauth logout handler. implement crew assignment to hold. this caused a lot of circular dependencies and needed to move functions around in order to fix 2025-10-24 23:51:32 -05:00
Evan Jarrett 9eb69e2ea7 more linting fixes 2025-10-24 01:05:19 -05:00
Evan Jarrett 751fa1a3f0 post to bluesky when manifests uploaded. linting fixes 2025-10-23 12:24:04 -05:00
Evan Jarrett ce7160cdca add backlinks to tags 2025-10-21 09:29:40 -05:00
Evan Jarrett 5d52007104 general bug fixes 2025-10-21 09:21:51 -05:00
Evan Jarrett 48414be75d lots of unit testing for xrpc endpoints. start pointing appview to the new endpoints. remove legacy api endpoints 2025-10-17 15:41:20 -05:00
Evan Jarrett 70e802764b crazy refactor to start using holds embedded pds for crew/captain validation 2025-10-16 00:05:45 -05:00
Evan Jarrett ab7e0f07ac fix authentication message 2025-10-12 22:45:44 -05:00
Evan Jarrett 9a64804807 better unauth message 2025-10-12 22:30:24 -05:00
Evan Jarrett 9025c89cc6 attempt gorelease and goat builds 2025-10-12 22:09:03 -05:00