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>
This commit is contained in:
Evan Jarrett
2026-08-09 20:52:59 -05:00
co-authored by Claude Opus 5
parent e6959e6dc6
commit 9d4ad84a3e
2 changed files with 41 additions and 0 deletions
+31
View File
@@ -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 // getErrorHint provides context-specific troubleshooting hints based on API error type
func getErrorHint(apiErr *atclient.APIError) string { func getErrorHint(apiErr *atclient.APIError) string {
switch apiErr.Name { switch apiErr.Name {
@@ -414,6 +430,21 @@ func GetOrFetchServiceTokenWithAppPassword(
return "", fmt.Errorf("app-password token stale (%s): re-authentication required", name) 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)", slog.Error("Service token request returned non-200 status (app-password)",
"component", "token/servicetoken", "component", "token/servicetoken",
"did", did, "did", did,
+10
View File
@@ -506,6 +506,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
"duration", fetchDur.Round(time.Millisecond)) "duration", fetchDur.Round(time.Millisecond))
} }
if res.err != nil { 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) 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))) _ = errcode.ServeJSON(w, errcode.ErrorCodeUnavailable.WithMessage(fmt.Sprintf("service-auth fetch failed: %v", res.err)))
return return