| 1 |
|
package bearer |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"errors" |
| 5 |
|
"net/http" |
| 6 |
|
"strconv" |
| 7 |
|
) |
| 8 |
|
|
| 9 |
|
// StatusFor is the instance's answer to "a token was refused — what does the |
| 10 |
|
// caller see?", as one table rather than one per service. |
| 11 |
|
// |
| 12 |
|
// The mapping itself is not subtle. What makes it worth sharing is the arm |
| 13 |
|
// that is easy to get wrong and impossible to notice: ErrUnavailable is 503, |
| 14 |
|
// never 401. Reading an unreachable token daemon as "revoked" tells every CI |
| 15 |
|
// job on the instance that its credential is bad for as long as tokens.sr.ht |
| 16 |
|
// takes to restart, and somebody spends the evening re-minting tokens that |
| 17 |
|
// were never broken. bench alone had this switch written out three times — in |
| 18 |
|
// its REST surface, its MCP surface and its resolver — which is three chances |
| 19 |
|
// to fold the unreachable case into the invalid one. |
| 20 |
|
// |
| 21 |
|
// The three refusals that ARE the caller's fault share one status and one |
| 22 |
|
// sentence on purpose: telling a prober "that token exists but is revoked" is |
| 23 |
|
// information they have not earned. Which of them it was belongs in the log. |
| 24 |
|
// |
| 25 |
|
// ErrNotOurs is the one arm a service must decide before asking: it means a |
| 26 |
|
// well-formed token from another issuer, almost certainly a meta.sr.ht PAT, |
| 27 |
|
// and SPEC ch. 6 step 2 leaves each service to accept it (dolt) or refuse it |
| 28 |
|
// (bench, cover). Handle it first; reaching here it is a refusal, so it maps |
| 29 |
|
// to 401. |
| 30 |
|
// |
| 31 |
|
// A nil error maps to 200 so a caller can write the status unconditionally. |
| 32 |
9 |
func StatusFor(err error) int { |
| 33 |
9 |
switch { |
| 34 |
1 |
case err == nil: |
| 35 |
1 |
return http.StatusOK |
| 36 |
2 |
case errors.Is(err, ErrForbidden): |
| 37 |
2 |
return http.StatusForbidden |
| 38 |
2 |
case errors.Is(err, ErrUnavailable): |
| 39 |
2 |
return http.StatusServiceUnavailable |
| 40 |
4 |
default: |
| 41 |
4 |
// ErrInvalid, ErrRevoked, ErrNotOurs, and anything a future validator |
| 42 |
4 |
// step adds: an unrecognised failure is the caller's credential, not |
| 43 |
4 |
// the instance's health. A new sentinel that deserves 503 has to say |
| 44 |
4 |
// so here, which is the point of the default going this way — a |
| 45 |
4 |
// forgotten arm refuses a request rather than declaring the service |
| 46 |
4 |
// unwell. |
| 47 |
4 |
return http.StatusUnauthorized |
| 48 |
|
} |
| 49 |
|
} |
| 50 |
|
|
| 51 |
|
// IsRefusal reports whether err is one this package decided — that is, whether |
| 52 |
|
// StatusFor's answer means anything for it. |
| 53 |
|
// |
| 54 |
|
// A service's own resolver returns more than bearer's vocabulary: the Postgres |
| 55 |
|
// lookup it had to make, a context that expired, a bug. Handing those to |
| 56 |
|
// StatusFor would answer 401 through its default arm, which is right for a |
| 57 |
|
// credential and wrong for a database that did not answer — a caller told its |
| 58 |
|
// token is bad re-mints a token that was never the problem. So a service that |
| 59 |
|
// wraps this table guards the delegation: |
| 60 |
|
// |
| 61 |
|
// if bearer.IsRefusal(err) { |
| 62 |
|
// http.Error(w, msg, bearer.StatusFor(err)) |
| 63 |
|
// return |
| 64 |
|
// } |
| 65 |
|
// // anything else is ours, not the caller's |
| 66 |
|
// |
| 67 |
|
// Without this, each service spells out the sentinel list again, which is the |
| 68 |
|
// five-line copy this package exists to stop. |
| 69 |
|
// |
| 70 |
|
// It answers for THIS package's vocabulary and nothing else, which is the trap |
| 71 |
|
// in the guard above: a service whose own refusals do not wrap these sentinels |
| 72 |
|
// sends its "bad token" straight into the else branch and answers 503 to a |
| 73 |
|
// caller whose credential really was the problem. Either wrap — an |
| 74 |
|
// ErrInvalidToken of your own that unwraps to ErrInvalid — or ask your own |
| 75 |
|
// predicate first and reach this one only for what it can have produced. |
| 76 |
13 |
func IsRefusal(err error) bool { |
| 77 |
13 |
for _, sentinel := range []error{ |
| 78 |
13 |
ErrInvalid, ErrNotOurs, ErrForbidden, ErrRevoked, ErrUnavailable, |
| 79 |
45 |
} { |
| 80 |
45 |
if errors.Is(err, sentinel) { |
| 81 |
10 |
return true |
| 82 |
10 |
} |
| 83 |
|
} |
| 84 |
3 |
return false |
| 85 |
|
} |
| 86 |
|
|
| 87 |
|
// Challenge is the WWW-Authenticate value a 401 carries: the scheme, and the |
| 88 |
|
// service's own config section as the realm. |
| 89 |
|
// |
| 90 |
|
// RFC 9110 requires the header on a 401, and every service on the instance was |
| 91 |
|
// assembling the same string from the same constant. The realm is the section |
| 92 |
|
// name ("bench.sr.ht") because that is what identifies the service everywhere |
| 93 |
|
// else on this instance — in the config, in the nav, in a grant. |
| 94 |
|
// |
| 95 |
|
// The realm is quoted per RFC 9110 §11.6.1; a quote or backslash in it would |
| 96 |
|
// end the parameter early, so the value is escaped rather than trusted. In |
| 97 |
|
// practice a section name contains neither. |
| 98 |
3 |
func Challenge(realm string) string { |
| 99 |
3 |
return "Bearer realm=" + strconv.Quote(realm) |
| 100 |
3 |
} |