package authn import ( "context" "errors" "fmt" "net/http" "strconv" "strings" "sourcecraft.dev/bigbes/sr-ht-ecore/bearer" ) // The grant vocabulary spec.sr.ht declares for tokens.sr.ht working tokens. // // The daemon that mints them does not know these strings and must not: the // tokens.sr.ht spec gives the vocabulary to the services, so that adding an // action to spec.sr.ht is a change to spec.sr.ht. An unknown grant simply // admits nobody. They are constants rather than literals at the check because a // grant is compared byte for byte — a typo in one of the two places it is // spelled is a silent widening or a silent refusal, and neither shows up until // it matters. const ( // ActionPropose is what an instance token must carry to write: open a // proposal or add documents to one. ActionPropose = "spec:propose" // ActionRead is what an instance token must carry to read content through // any of the read surfaces (the web UI, /query, MCP). ActionRead = "spec:read" ) // BearerValidator is the sliver of sr-ht-ecore's bearer.Validator this package // needs: who a presented working token belongs to, what it permits, and whether // it is still live. // // Inspect and not Validate, because the resolver runs in middleware upstream of // the router and so does not know which action is being attempted. The grant // check happens where the action is known — service.Propose for the write // plane, the read gates for the read plane — through Principal.Authorize. // // It is an interface rather than a *bearer.Validator so that this package stays // testable without a tokens.sr.ht to talk to, exactly as TokenStore keeps it // testable without a Postgres. type BearerValidator interface { Inspect(ctx context.Context, presented string) (*bearer.Token, error) } // InstanceUser is the local "user" row that the owner of an instance token // resolves to. It is the whole of what this package needs from that row: the id // other layers key user-scoped state by, and the name it was found under. type InstanceUser struct { ID int Username string } // UserLookup resolves the meta.sr.ht username an instance token names into this // service's local user row — core-go's auth.LookupUser in production. // // It is declared here for the same reason TokenStore is: that function reads a // database handle and a config out of the context and panics without either, // which is service/'s business to supply and not something a package answering // "who is making this request?" should carry. service/ wires the real one in; // tests wire a map. type UserLookup interface { LookupUser(ctx context.Context, username string) (InstanceUser, error) } // resolveInstanceToken runs the tokens.sr.ht plane against a presented bearer // credential. Its answer is final: there is one agent credential plane, so a // refusal here is the service's refusal. // // It used to report a third thing — whether the caller should fall back to // spec's own agent_token store — and exactly two refusals said yes: // // - bearer.ErrInvalid, because spec's local token was 32 random bytes in // base64, which is precisely what "did not decode as one of ours" looks // like; // - bearer.ErrNotOurs, because refusing a meta.sr.ht PAT was the local plane's // business rather than this one's, and falling through cost one hash lookup // that would miss. // // With that store gone both are plain refusals. The one consequence worth // naming is ErrNotOurs: IsAuthFailure now counts it permanent, so a meta PAT // presented here earns a 401 rather than the 503 an unclassified error would. // // The rest of the mapping is unchanged and lives in StatusFor: ErrInvalid and // ErrRevoked are 401, ErrForbidden and a foreign owner are 403, and // ErrUnavailable is 503 — never 401, because "I could not ask tokens.sr.ht" is // not "your token is bad". func (rs *Resolver) resolveInstanceToken( ctx context.Context, presented, agent, session string, ) (Principal, error) { tok, err := rs.bearer.Inspect(ctx, presented) if err != nil { return Anonymous(), fmt.Errorf("authn: instance token: %w", err) } // The token names a meta.sr.ht account, and spec.sr.ht has exactly one that // means anything. This is the same rule the cookie plane already applies — // a real user who is not the instance owner reads as nobody — and applying // it here keeps every consumer of Principal.Owner honest: the provenance // committer, the refs rule's principal kind and coreauth's AuthContext all // assume the human an agent acts for is the instance owner, and a foreign // name would make each of them quietly wrong in a different way. // // It is a refusal rather than a downgrade to anonymous because a presented // credential that fails must fail at the door: the asymmetry this package's // doc comment draws between cookies and bearer tokens. username := strings.TrimPrefix(tok.Username, "~") if username != rs.owner { return Anonymous(), fmt.Errorf( "%w: the token belongs to ~%s, and this instance answers only to ~%s", ErrNotInstanceOwner, username, rs.owner) } // The owner is resolved to a local row even though single-user spec could // infer it: the row id is what user-scoped state keys off, and looking it up // here is what makes the instance plane's identity a fact about this // database rather than a name copied out of a signed blob. user, err := rs.users.LookupUser(ctx, username) if err != nil { // Unclassified, therefore transient, therefore 503: a database that // cannot answer must never read as a bad credential. return Anonymous(), fmt.Errorf("authn: resolve instance token owner ~%s: %w", username, err) } return Principal{ Kind: KindAgent, Owner: rs.owner, Agent: agent, Session: session, TokenName: instanceTokenLabel(tok), Plane: PlaneInstance, Grants: tok.Grants, UserID: user.ID, }, nil } // instanceTokenLabel names the credential in a log line. A registered token has // a row at tokens.sr.ht an operator can find and revoke, so its id is the useful // thing to print; a stateless one was never written down, and saying so is more // honest than printing "0". func instanceTokenLabel(tok *bearer.Token) string { if tok.Registered() { return "tokens.sr.ht #" + strconv.Itoa(tok.TokenID) } return "tokens.sr.ht (stateless)" } // StatusFor maps an error out of Resolve — or out of a later Authorize — onto // the status the surface must answer with. It is one function so that the three // surfaces cannot each invent their own table. // // Everything the credential itself can be wrong about is bearer.StatusFor's // answer, not a second copy of it: ErrForbidden is 403, ErrUnavailable is 503 // and never 401, and ErrInvalid, ErrRevoked and ErrNotOurs are 401. That last // arm is only reached because ErrNotOurs is decided before we ask — a meta.sr.ht // PAT used to fall through to spec's own token store, and with that store gone // it is a refusal at the door. // // The ErrUnavailable line is the one worth restating even though it is no longer // spelled here. Reading "I could not reach tokens.sr.ht" as "your token is // revoked" would refuse every live instance token for as long as a daemon that // is deliberately off the hot path takes to restart, and would tell a thousand // clients their credentials are bad when the truth is that one service is down. // // What this function adds is what bearer cannot know: // // - ErrMissingGrant and ErrNotInstanceOwner are 403. The credential verifies // and the holder is who they say they are, so retrying is pointless and what // they need is a wider grant, not another login. Both are asked before the // bearer table, because ErrMissingGrant is raised beside a token that // verified and must not be read as one that did not. // - Whatever else IsAuthFailure calls permanent is 401 — today that is // ErrNoToken, nothing having been presented on a surface that requires a // credential. The predicate is asked rather than the sentinel listed a second // time, so that a sentinel added to one of them cannot be missing from the // other: this package's two answers to "is the credential the problem?" have // to agree, and the cheapest way to guarantee that is for one to be built // from the other. // - ErrNoAgentPlane, and anything else at all, is 503. An instance with no // [tokens.sr.ht] origin cannot check any credential, and telling the holder // of a good token that it is bad would send them to re-provision it; an // unclassified error is a backend that could not answer. This is where the // two tables' defaults deliberately differ — bearer's unrecognised failure // is the caller's credential, because everything reaching it is about a // credential, while an unrecognised failure here can be the database this // resolver had to consult, which must never read as a bad token. func StatusFor(err error) int { switch { case err == nil: return http.StatusOK case errors.Is(err, ErrMissingGrant), errors.Is(err, ErrNotInstanceOwner): return http.StatusForbidden case isBearerRefusal(err): return bearer.StatusFor(err) case IsAuthFailure(err): return http.StatusUnauthorized default: return http.StatusServiceUnavailable } } // isBearerRefusal reports whether err is one of the sentinels bearer.StatusFor // has an answer for. The list is here rather than in a helper over there because // it is the question "did the shared validator decide this?", and a wrong answer // to it is what would let the 503 default below swallow a 401 — or, worse, let // bearer's own 401 default swallow a database outage. func isBearerRefusal(err error) bool { return errors.Is(err, bearer.ErrForbidden) || errors.Is(err, bearer.ErrUnavailable) || errors.Is(err, bearer.ErrInvalid) || errors.Is(err, bearer.ErrRevoked) || errors.Is(err, bearer.ErrNotOurs) } // Challenge is the WWW-Authenticate value every 401 this service answers must // carry, per RFC 9110 §11.6.1 — the scheme, and this service's config section as // the realm, which is what names it in the config, in the nav and in a grant // everywhere else on the instance. // // It is bearer.Challenge with our section already in it, so that the four // surfaces that refuse a credential (the resolver's middleware, MCP, /query and // the read plane's machine formats) cannot name four realms. func Challenge() string { return bearer.Challenge(ConfigSection) }