package bearer import ( "errors" "net/http" "strconv" ) // StatusFor is the instance's answer to "a token was refused — what does the // caller see?", as one table rather than one per service. // // The mapping itself is not subtle. What makes it worth sharing is the arm // that is easy to get wrong and impossible to notice: ErrUnavailable is 503, // never 401. Reading an unreachable token daemon as "revoked" tells every CI // job on the instance that its credential is bad for as long as tokens.sr.ht // takes to restart, and somebody spends the evening re-minting tokens that // were never broken. bench alone had this switch written out three times — in // its REST surface, its MCP surface and its resolver — which is three chances // to fold the unreachable case into the invalid one. // // The three refusals that ARE the caller's fault share one status and one // sentence on purpose: telling a prober "that token exists but is revoked" is // information they have not earned. Which of them it was belongs in the log. // // ErrNotOurs is the one arm a service must decide before asking: it means a // well-formed token from another issuer, almost certainly a meta.sr.ht PAT, // and SPEC ch. 6 step 2 leaves each service to accept it (dolt) or refuse it // (bench, cover). Handle it first; reaching here it is a refusal, so it maps // to 401. // // A nil error maps to 200 so a caller can write the status unconditionally. func StatusFor(err error) int { switch { case err == nil: return http.StatusOK case errors.Is(err, ErrForbidden): return http.StatusForbidden case errors.Is(err, ErrUnavailable): return http.StatusServiceUnavailable default: // ErrInvalid, ErrRevoked, ErrNotOurs, and anything a future validator // step adds: an unrecognised failure is the caller's credential, not // the instance's health. A new sentinel that deserves 503 has to say // so here, which is the point of the default going this way — a // forgotten arm refuses a request rather than declaring the service // unwell. return http.StatusUnauthorized } } // IsRefusal reports whether err is one this package decided — that is, whether // StatusFor's answer means anything for it. // // A service's own resolver returns more than bearer's vocabulary: the Postgres // lookup it had to make, a context that expired, a bug. Handing those to // StatusFor would answer 401 through its default arm, which is right for a // credential and wrong for a database that did not answer — a caller told its // token is bad re-mints a token that was never the problem. So a service that // wraps this table guards the delegation: // // if bearer.IsRefusal(err) { // http.Error(w, msg, bearer.StatusFor(err)) // return // } // // anything else is ours, not the caller's // // Without this, each service spells out the sentinel list again, which is the // five-line copy this package exists to stop. // // It answers for THIS package's vocabulary and nothing else, which is the trap // in the guard above: a service whose own refusals do not wrap these sentinels // sends its "bad token" straight into the else branch and answers 503 to a // caller whose credential really was the problem. Either wrap — an // ErrInvalidToken of your own that unwraps to ErrInvalid — or ask your own // predicate first and reach this one only for what it can have produced. func IsRefusal(err error) bool { for _, sentinel := range []error{ ErrInvalid, ErrNotOurs, ErrForbidden, ErrRevoked, ErrUnavailable, } { if errors.Is(err, sentinel) { return true } } return false } // Challenge is the WWW-Authenticate value a 401 carries: the scheme, and the // service's own config section as the realm. // // RFC 9110 requires the header on a 401, and every service on the instance was // assembling the same string from the same constant. The realm is the section // name ("bench.sr.ht") because that is what identifies the service everywhere // else on this instance — in the config, in the nav, in a grant. // // The realm is quoted per RFC 9110 §11.6.1; a quote or backslash in it would // end the parameter early, so the value is escaped rather than trusted. In // practice a section name contains neither. func Challenge(realm string) string { return "Bearer realm=" + strconv.Quote(realm) }