From 9d4ad84a3ecc4f30525c22efc5ac3205c096883d Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Sun, 9 Aug 2026 20:52:59 -0500 Subject: [PATCH] auth: surface read-only app passwords as 403, not 503 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pkg/auth/servicetoken.go | 31 +++++++++++++++++++++++++++++++ pkg/auth/token/handler.go | 10 ++++++++++ 2 files changed, 41 insertions(+) diff --git a/pkg/auth/servicetoken.go b/pkg/auth/servicetoken.go index 27b55c3..dc2a8e2 100644 --- a/pkg/auth/servicetoken.go +++ b/pkg/auth/servicetoken.go @@ -48,6 +48,22 @@ func isStaleBearerToken(name string) bool { } } +// ErrAppPasswordInsufficientScope indicates the app-password session lacks the +// scope to mint a hold service-auth token via com.atproto.server.getServiceAuth. +// Read-only app passwords trip this: the PDS rejects getServiceAuth with 403 +// InsufficientScope. It is a permanent authorization failure — retrying won't +// help — so callers surface it as a 403 with a clear "use a full-access app +// password" message rather than a 503. +var ErrAppPasswordInsufficientScope = errors.New("app-password lacks scope to mint hold service token (read-only app password?)") + +// isInsufficientScopeError reports whether a PDS error body signals that the +// presented app-password session is scoped too narrowly to call getServiceAuth. +// The reference PDS and tranquil both return the atproto error name +// "InsufficientScope" (paired with 403) for read-only app passwords. +func isInsufficientScopeError(body []byte) bool { + return atprotoErrorName(body) == "InsufficientScope" +} + // getErrorHint provides context-specific troubleshooting hints based on API error type func getErrorHint(apiErr *atclient.APIError) string { switch apiErr.Name { @@ -414,6 +430,21 @@ func GetOrFetchServiceTokenWithAppPassword( return "", fmt.Errorf("app-password token stale (%s): re-authentication required", name) } + // The app-password session is valid but scoped too narrowly to mint a + // hold service-auth token (getServiceAuth is a privileged endpoint that + // read-only app passwords can't call). This is a permanent authorization + // failure — no cached token to evict, and retrying won't help — so flag it + // with a sentinel the caller maps to a 403 with a clear message. + if resp.StatusCode == http.StatusForbidden && isInsufficientScopeError(bodyBytes) { + slog.Warn("App-password lacks scope for getServiceAuth (read-only app password?)", + "component", "token/servicetoken", + "did", did, + "holdDID", holdDID, + "pdsEndpoint", pdsEndpoint, + "hint", "User must use a full-access app password, not a read-only one") + return "", fmt.Errorf("%w: %s", ErrAppPasswordInsufficientScope, string(bodyBytes)) + } + slog.Error("Service token request returned non-200 status (app-password)", "component", "token/servicetoken", "did", did, diff --git a/pkg/auth/token/handler.go b/pkg/auth/token/handler.go index ae5d86d..e42d4bd 100644 --- a/pkg/auth/token/handler.go +++ b/pkg/auth/token/handler.go @@ -506,6 +506,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { "duration", fetchDur.Round(time.Millisecond)) } if res.err != nil { + // A read-only app password can authenticate (createSession) but can't + // mint the hold service-auth token that pulls and pushes require. That + // is a permanent authorization failure, not a transient outage, so + // return a 403 with actionable guidance instead of a retry-inviting 503. + if errors.Is(res.err, auth.ErrAppPasswordInsufficientScope) { + slog.Info("service-auth pre-mint denied: app-password lacks scope", "did", did, "error", res.err) + _ = errcode.ServeJSON(w, errcode.ErrorCodeDenied.WithMessage( + "your app password lacks the permissions ATCR needs. A read-only app password cannot mint the service token used to authenticate with your storage hold. Use a standard (full-access) app password.")) + return + } slog.Warn("service-auth pre-mint failed", "did", did, "error", res.err) _ = errcode.ServeJSON(w, errcode.ErrorCodeUnavailable.WithMessage(fmt.Sprintf("service-auth fetch failed: %v", res.err))) return