| 1 |
|
package authn |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"errors" |
| 6 |
|
"fmt" |
| 7 |
|
"net/http" |
| 8 |
|
"strconv" |
| 9 |
|
"strings" |
| 10 |
|
|
| 11 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/bearer" |
| 12 |
|
) |
| 13 |
|
|
| 14 |
|
// The grant vocabulary spec.sr.ht declares for tokens.sr.ht working tokens. |
| 15 |
|
// |
| 16 |
|
// The daemon that mints them does not know these strings and must not: the |
| 17 |
|
// tokens.sr.ht spec gives the vocabulary to the services, so that adding an |
| 18 |
|
// action to spec.sr.ht is a change to spec.sr.ht. An unknown grant simply |
| 19 |
|
// admits nobody. They are constants rather than literals at the check because a |
| 20 |
|
// grant is compared byte for byte — a typo in one of the two places it is |
| 21 |
|
// spelled is a silent widening or a silent refusal, and neither shows up until |
| 22 |
|
// it matters. |
| 23 |
|
const ( |
| 24 |
|
// ActionPropose is what an instance token must carry to write: open a |
| 25 |
|
// proposal or add documents to one. |
| 26 |
|
ActionPropose = "spec:propose" |
| 27 |
|
|
| 28 |
|
// ActionRead is what an instance token must carry to read content through |
| 29 |
|
// any of the read surfaces (the web UI, /query, MCP). |
| 30 |
|
ActionRead = "spec:read" |
| 31 |
|
) |
| 32 |
|
|
| 33 |
|
// BearerValidator is the sliver of sr-ht-ecore's bearer.Validator this package |
| 34 |
|
// needs: who a presented working token belongs to, what it permits, and whether |
| 35 |
|
// it is still live. |
| 36 |
|
// |
| 37 |
|
// Inspect and not Validate, because the resolver runs in middleware upstream of |
| 38 |
|
// the router and so does not know which action is being attempted. The grant |
| 39 |
|
// check happens where the action is known — service.Propose for the write |
| 40 |
|
// plane, the read gates for the read plane — through Principal.Authorize. |
| 41 |
|
// |
| 42 |
|
// It is an interface rather than a *bearer.Validator so that this package stays |
| 43 |
|
// testable without a tokens.sr.ht to talk to, exactly as TokenStore keeps it |
| 44 |
|
// testable without a Postgres. |
| 45 |
|
type BearerValidator interface { |
| 46 |
|
Inspect(ctx context.Context, presented string) (*bearer.Token, error) |
| 47 |
|
} |
| 48 |
|
|
| 49 |
|
// InstanceUser is the local "user" row that the owner of an instance token |
| 50 |
|
// resolves to. It is the whole of what this package needs from that row: the id |
| 51 |
|
// other layers key user-scoped state by, and the name it was found under. |
| 52 |
|
type InstanceUser struct { |
| 53 |
|
ID int |
| 54 |
|
Username string |
| 55 |
|
} |
| 56 |
|
|
| 57 |
|
// UserLookup resolves the meta.sr.ht username an instance token names into this |
| 58 |
|
// service's local user row — core-go's auth.LookupUser in production. |
| 59 |
|
// |
| 60 |
|
// It is declared here for the same reason TokenStore is: that function reads a |
| 61 |
|
// database handle and a config out of the context and panics without either, |
| 62 |
|
// which is service/'s business to supply and not something a package answering |
| 63 |
|
// "who is making this request?" should carry. service/ wires the real one in; |
| 64 |
|
// tests wire a map. |
| 65 |
|
type UserLookup interface { |
| 66 |
|
LookupUser(ctx context.Context, username string) (InstanceUser, error) |
| 67 |
|
} |
| 68 |
|
|
| 69 |
|
// resolveInstanceToken runs the tokens.sr.ht plane against a presented bearer |
| 70 |
|
// credential. Its answer is final: there is one agent credential plane, so a |
| 71 |
|
// refusal here is the service's refusal. |
| 72 |
|
// |
| 73 |
|
// It used to report a third thing — whether the caller should fall back to |
| 74 |
|
// spec's own agent_token store — and exactly two refusals said yes: |
| 75 |
|
// |
| 76 |
|
// - bearer.ErrInvalid, because spec's local token was 32 random bytes in |
| 77 |
|
// base64, which is precisely what "did not decode as one of ours" looks |
| 78 |
|
// like; |
| 79 |
|
// - bearer.ErrNotOurs, because refusing a meta.sr.ht PAT was the local plane's |
| 80 |
|
// business rather than this one's, and falling through cost one hash lookup |
| 81 |
|
// that would miss. |
| 82 |
|
// |
| 83 |
|
// With that store gone both are plain refusals. The one consequence worth |
| 84 |
|
// naming is ErrNotOurs: IsAuthFailure now counts it permanent, so a meta PAT |
| 85 |
|
// presented here earns a 401 rather than the 503 an unclassified error would. |
| 86 |
|
// |
| 87 |
|
// The rest of the mapping is unchanged and lives in StatusFor: ErrInvalid and |
| 88 |
|
// ErrRevoked are 401, ErrForbidden and a foreign owner are 403, and |
| 89 |
|
// ErrUnavailable is 503 — never 401, because "I could not ask tokens.sr.ht" is |
| 90 |
|
// not "your token is bad". |
| 91 |
|
func (rs *Resolver) resolveInstanceToken( |
| 92 |
|
ctx context.Context, presented, agent, session string, |
| 93 |
24 |
) (Principal, error) { |
| 94 |
24 |
tok, err := rs.bearer.Inspect(ctx, presented) |
| 95 |
24 |
if err != nil { |
| 96 |
13 |
return Anonymous(), fmt.Errorf("authn: instance token: %w", err) |
| 97 |
13 |
} |
| 98 |
|
|
| 99 |
|
// The token names a meta.sr.ht account, and spec.sr.ht has exactly one that |
| 100 |
|
// means anything. This is the same rule the cookie plane already applies — |
| 101 |
|
// a real user who is not the instance owner reads as nobody — and applying |
| 102 |
|
// it here keeps every consumer of Principal.Owner honest: the provenance |
| 103 |
|
// committer, the refs rule's principal kind and coreauth's AuthContext all |
| 104 |
|
// assume the human an agent acts for is the instance owner, and a foreign |
| 105 |
|
// name would make each of them quietly wrong in a different way. |
| 106 |
|
// |
| 107 |
|
// It is a refusal rather than a downgrade to anonymous because a presented |
| 108 |
|
// credential that fails must fail at the door: the asymmetry this package's |
| 109 |
|
// doc comment draws between cookies and bearer tokens. |
| 110 |
11 |
username := strings.TrimPrefix(tok.Username, "~") |
| 111 |
11 |
if username != rs.owner { |
| 112 |
2 |
return Anonymous(), fmt.Errorf( |
| 113 |
2 |
"%w: the token belongs to ~%s, and this instance answers only to ~%s", |
| 114 |
2 |
ErrNotInstanceOwner, username, rs.owner) |
| 115 |
2 |
} |
| 116 |
|
|
| 117 |
|
// The owner is resolved to a local row even though single-user spec could |
| 118 |
|
// infer it: the row id is what user-scoped state keys off, and looking it up |
| 119 |
|
// here is what makes the instance plane's identity a fact about this |
| 120 |
|
// database rather than a name copied out of a signed blob. |
| 121 |
9 |
user, err := rs.users.LookupUser(ctx, username) |
| 122 |
9 |
if err != nil { |
| 123 |
3 |
// Unclassified, therefore transient, therefore 503: a database that |
| 124 |
3 |
// cannot answer must never read as a bad credential. |
| 125 |
3 |
return Anonymous(), fmt.Errorf("authn: resolve instance token owner ~%s: %w", username, err) |
| 126 |
3 |
} |
| 127 |
|
|
| 128 |
6 |
return Principal{ |
| 129 |
6 |
Kind: KindAgent, |
| 130 |
6 |
Owner: rs.owner, |
| 131 |
6 |
Agent: agent, |
| 132 |
6 |
Session: session, |
| 133 |
6 |
TokenName: instanceTokenLabel(tok), |
| 134 |
6 |
Plane: PlaneInstance, |
| 135 |
6 |
Grants: tok.Grants, |
| 136 |
6 |
UserID: user.ID, |
| 137 |
6 |
}, nil |
| 138 |
|
} |
| 139 |
|
|
| 140 |
|
// instanceTokenLabel names the credential in a log line. A registered token has |
| 141 |
|
// a row at tokens.sr.ht an operator can find and revoke, so its id is the useful |
| 142 |
|
// thing to print; a stateless one was never written down, and saying so is more |
| 143 |
|
// honest than printing "0". |
| 144 |
6 |
func instanceTokenLabel(tok *bearer.Token) string { |
| 145 |
6 |
if tok.Registered() { |
| 146 |
0 |
return "tokens.sr.ht #" + strconv.Itoa(tok.TokenID) |
| 147 |
0 |
} |
| 148 |
6 |
return "tokens.sr.ht (stateless)" |
| 149 |
|
} |
| 150 |
|
|
| 151 |
|
// StatusFor maps an error out of Resolve — or out of a later Authorize — onto |
| 152 |
|
// the status the surface must answer with. It is one function so that the three |
| 153 |
|
// surfaces cannot each invent their own table. |
| 154 |
|
// |
| 155 |
|
// Everything the credential itself can be wrong about is bearer.StatusFor's |
| 156 |
|
// answer, not a second copy of it: ErrForbidden is 403, ErrUnavailable is 503 |
| 157 |
|
// and never 401, and ErrInvalid, ErrRevoked and ErrNotOurs are 401. That last |
| 158 |
|
// arm is only reached because ErrNotOurs is decided before we ask — a meta.sr.ht |
| 159 |
|
// PAT used to fall through to spec's own token store, and with that store gone |
| 160 |
|
// it is a refusal at the door. |
| 161 |
|
// |
| 162 |
|
// The ErrUnavailable line is the one worth restating even though it is no longer |
| 163 |
|
// spelled here. Reading "I could not reach tokens.sr.ht" as "your token is |
| 164 |
|
// revoked" would refuse every live instance token for as long as a daemon that |
| 165 |
|
// is deliberately off the hot path takes to restart, and would tell a thousand |
| 166 |
|
// clients their credentials are bad when the truth is that one service is down. |
| 167 |
|
// |
| 168 |
|
// What this function adds is what bearer cannot know: |
| 169 |
|
// |
| 170 |
|
// - ErrMissingGrant and ErrNotInstanceOwner are 403. The credential verifies |
| 171 |
|
// and the holder is who they say they are, so retrying is pointless and what |
| 172 |
|
// they need is a wider grant, not another login. Both are asked before the |
| 173 |
|
// bearer table, because ErrMissingGrant is raised beside a token that |
| 174 |
|
// verified and must not be read as one that did not. |
| 175 |
|
// - Whatever else IsAuthFailure calls permanent is 401 — today that is |
| 176 |
|
// ErrNoToken, nothing having been presented on a surface that requires a |
| 177 |
|
// credential. The predicate is asked rather than the sentinel listed a second |
| 178 |
|
// time, so that a sentinel added to one of them cannot be missing from the |
| 179 |
|
// other: this package's two answers to "is the credential the problem?" have |
| 180 |
|
// to agree, and the cheapest way to guarantee that is for one to be built |
| 181 |
|
// from the other. |
| 182 |
|
// - ErrNoAgentPlane, and anything else at all, is 503. An instance with no |
| 183 |
|
// [tokens.sr.ht] origin cannot check any credential, and telling the holder |
| 184 |
|
// of a good token that it is bad would send them to re-provision it; an |
| 185 |
|
// unclassified error is a backend that could not answer. This is where the |
| 186 |
|
// two tables' defaults deliberately differ — bearer's unrecognised failure |
| 187 |
|
// is the caller's credential, because everything reaching it is about a |
| 188 |
|
// credential, while an unrecognised failure here can be the database this |
| 189 |
|
// resolver had to consult, which must never read as a bad token. |
| 190 |
30 |
func StatusFor(err error) int { |
| 191 |
30 |
switch { |
| 192 |
1 |
case err == nil: |
| 193 |
1 |
return http.StatusOK |
| 194 |
5 |
case errors.Is(err, ErrMissingGrant), errors.Is(err, ErrNotInstanceOwner): |
| 195 |
5 |
return http.StatusForbidden |
| 196 |
16 |
case isBearerRefusal(err): |
| 197 |
16 |
return bearer.StatusFor(err) |
| 198 |
1 |
case IsAuthFailure(err): |
| 199 |
1 |
return http.StatusUnauthorized |
| 200 |
7 |
default: |
| 201 |
7 |
return http.StatusServiceUnavailable |
| 202 |
|
} |
| 203 |
|
} |
| 204 |
|
|
| 205 |
|
// isBearerRefusal reports whether err is one of the sentinels bearer.StatusFor |
| 206 |
|
// has an answer for. The list is here rather than in a helper over there because |
| 207 |
|
// it is the question "did the shared validator decide this?", and a wrong answer |
| 208 |
|
// to it is what would let the 503 default below swallow a 401 — or, worse, let |
| 209 |
|
// bearer's own 401 default swallow a database outage. |
| 210 |
24 |
func isBearerRefusal(err error) bool { |
| 211 |
24 |
return errors.Is(err, bearer.ErrForbidden) || |
| 212 |
24 |
errors.Is(err, bearer.ErrUnavailable) || |
| 213 |
24 |
errors.Is(err, bearer.ErrInvalid) || |
| 214 |
24 |
errors.Is(err, bearer.ErrRevoked) || |
| 215 |
24 |
errors.Is(err, bearer.ErrNotOurs) |
| 216 |
24 |
} |
| 217 |
|
|
| 218 |
|
// Challenge is the WWW-Authenticate value every 401 this service answers must |
| 219 |
|
// carry, per RFC 9110 §11.6.1 — the scheme, and this service's config section as |
| 220 |
|
// the realm, which is what names it in the config, in the nav and in a grant |
| 221 |
|
// everywhere else on the instance. |
| 222 |
|
// |
| 223 |
|
// It is bearer.Challenge with our section already in it, so that the four |
| 224 |
|
// surfaces that refuse a credential (the resolver's middleware, MCP, /query and |
| 225 |
|
// the read plane's machine formats) cannot name four realms. |
| 226 |
6 |
func Challenge() string { return bearer.Challenge(ConfigSection) } |