package authn import ( "context" "errors" "fmt" "net/http" "strings" "sourcecraft.dev/bigbes/sr-ht-core/auth" "sourcecraft.dev/bigbes/sr-ht-ecore/bearer" "sourcecraft.dev/bigbes/sr-ht-ecore/grants" "sourcecraft.dev/bigbes/sr-ht-dolt/core" ) // AuthMethodInstanceToken labels an AuthContext resolved from a tokens.sr.ht // working token. Like AuthMethodDoltKey it is a private label: core-go has no // such method, and we never call auth.AuthContext.Access (which would panic on // an unknown one) — access is decided by core.Allowed — so the label costs // nothing and keeps this plane distinguishable from a meta.sr.ht PAT in a log // line or a debugger. const AuthMethodInstanceToken = "INSTANCE_TOKEN" // bearerScheme is the RFC 7235 auth-scheme this plane answers to. The // comparison against it is case-insensitive, as that RFC requires. const bearerScheme = "Bearer" // ErrMissingGrant is the sentinel wrapped by every "the credential is good, it // does not cover this" refusal: a working token without core.GrantRead, or a // meta PAT whose OAuth grants do not reach dolt.sr.ht repositories. It is a 403 // — retrying with the same token is pointless, and the holder needs to be told // to ask for a wider grant rather than to authenticate again. // // Where it is returned by ResolveBearer it is joined with ErrInvalidToken, so // that the two-class contract of backend.go stays total: a caller that only // knows "wraps ErrInvalidToken ⇒ permanent, otherwise ⇒ transient" still // answers 401 and not 503, while a caller that can say 403 asks for this // sentinel first. Authorize is not part of that contract — it is asked after // resolution succeeded, by a surface that already knows this package — so it // wraps this sentinel alone. var ErrMissingGrant = errors.New("authn: credential does not carry the required grant") // ParseBearer returns the token from an "Authorization: Bearer " header, // or "" when the header is absent or names another scheme. // // Another scheme is silently no token rather than an error. Basic is dolt's own // remote flow (ResolveBasic) and is not this plane's to reject; a request // holding one, or holding nothing at all, must fall through to whatever the // caller does with an anonymous request, not be refused in the name of a // credential it never claimed to present. func ParseBearer(r *http.Request) string { h := r.Header.Get("Authorization") if h == "" { return "" } scheme, rest, ok := strings.Cut(h, " ") if !ok || !strings.EqualFold(scheme, bearerScheme) { return "" } return strings.TrimSpace(rest) } // InstanceValidator is the slice of sr-ht-ecore's bearer.Validator this plane // uses: the four steps of the tokens SPEC ch. 6 minus the grant check, which is // asked where the action is known and therefore not here (see bearer.Inspect, // and BearerCaller.Authorize below). // // The interface is declared in the consumer, as MetaBackend is and for the same // reason: it states exactly how much of the shared validator this service // depends on — one method — and it is what lets both arms of ResolveBearer be // tested without a tokens.sr.ht, without a network and without Postgres. type InstanceValidator interface { // Inspect verifies signature, version and expiry locally, checks that the // token is one tokens.sr.ht sealed, and asks the daemon whether a registered // token is still live. It answers with the owner, the parsed grants and the // row id, or with one of the bearer package's sentinels. Inspect(ctx context.Context, presented string) (*bearer.Token, error) } // Compile-time proof that the shared validator satisfies the port; it is what // lets this package depend on the interface rather than on *bearer.Validator, // and it fails the build the moment either side drifts. var _ InstanceValidator = (*bearer.Validator)(nil) // BearerCaller is what a presented bearer credential resolves to: the identity, // and — for a working token — what that token is allowed to ask for. // // It is deliberately small and it is not a second caller model. AuthContext is // the same *auth.AuthContext every other plane in this package produces, so // AsCoreCaller keeps working and the access matrix in package core is unchanged; // this type exists only so that a surface can ask the one further question a // tokens.sr.ht credential brings with it ("may this token do X?"), which an // AuthContext has no vocabulary for. type BearerCaller struct { // AuthContext is the resolved identity, never nil on a successful resolve. AuthContext *auth.AuthContext // InstanceToken reports which of the two bearer shapes this was: a // tokens.sr.ht working token (true) or a meta.sr.ht personal access token // (false). Only the ClientID distinguishes them — both are sealed with the // same instance key — and the difference decides which grant vocabulary // applies below. InstanceToken bool // Grants is the tokens.sr.ht grant set of a working token, parsed by // sr-ht-ecore/grants and by nothing else. It is the zero value — which // admits nothing — for a meta PAT, whose grants are in core-go's entirely // different OAuth vocabulary and live on AuthContext.Grants instead. Ask // Authorize rather than reading this field, so the distinction stays in one // place. Grants grants.Grants } // Authorize reports whether this caller may perform the named action, e.g. // core.GrantRead. // // A meta PAT passes unconditionally, and that is not a hole. It carries no // tokens.sr.ht grants at all — the vocabularies do not overlap — and its // scoping was already applied at resolve time, by the same TokenGrantsAllow // gate the clone path applies (docs/DESIGN.mcp.md §4.2: a meta PAT and an // anonymous caller pass this gate; their access is decided by core.Allowed). // Refusing it here would instead refuse every PAT on the surface, since no PAT // can ever be minted with a grant string tokens.sr.ht's parser would even read. // // A refusal wraps ErrMissingGrant: the credential is good and the caller is who // they say they are, and what is missing is a permission. func (c *BearerCaller) Authorize(grant string) error { if !c.InstanceToken { return nil } if !c.Grants.Has(grant) { return fmt.Errorf("%w: %q is not in %q", ErrMissingGrant, grant, c.Grants.String()) } return nil } // ResolveBearer resolves the caller for a bearer credential — the only machine // credential the /mcp surface accepts (docs/DESIGN.mcp.md §4.1). // // The instance issues two bearer shapes, both auth.BearerToken values sealed // with the same instance key, and only the ClientID tells them apart: // // - bearer.TokensClientID ⇒ a tokens.sr.ht working token. Verified through // sr-ht-ecore's validator (signature, version, expiry, ours-ness and, for a // registered token, the liveness check against the daemon), its owner // mirrored through the same MetaBackend the other planes use, and its grants // carried out on the result for BearerCaller.Authorize. // - anything else ⇒ a meta.sr.ht personal access token, resolved by // ResolveBasic — the decode/lookup/revocation path this package already has. // The one difference from the clone flow is that there is no presented // username to compare against, so the token's own username *is* the // identity. It is then gated by TokenGrantsAllow at core.AccessRO, the check // the clone path applies for a read. // // The ClientID is read here, by decoding the token once locally, rather than by // handing everything to Inspect and routing on bearer.ErrNotOurs. Both arms have // to work when there is no validator at all (see below), so the routing cannot // live inside the validator; and having it in one place beats having it twice. // The cost is one extra local HMAC on the working-token arm, which is the // cheapest step of the four. // // v may be nil, and that is a configuration rather than a degradation: an // instance whose config.ini has no [tokens.sr.ht] section has no such daemon. // Meta PATs and anonymity keep working; a working token is then refused with // ErrInvalidToken, because a machine credential this instance cannot verify is // refused and not guessed at. (A *typed* nil — (*bearer.Validator)(nil) in an // InstanceValidator — is not that contract and will panic; pass a plain nil.) // // Failure is a refusal and never a downgrade to anonymous. An empty presented // string is refused too: anonymity is the caller's decision, taken before this // function is reached (ParseBearer returning "" is what it is taken on), and an // empty credential arriving here is a caller that lost track of its own header. // // The error classes are backend.go's, unchanged: a permanent rejection wraps // ErrInvalidToken (401, additionally ErrMissingGrant for a 403), anything else // is a transient backend failure returned unwrapped (503). func ResolveBearer(ctx context.Context, v InstanceValidator, presented string) (*BearerCaller, error) { if presented == "" { return nil, fmt.Errorf("%w: no bearer token presented", ErrInvalidToken) } // Step 1 of the tokens SPEC for both arms at once: signature, version and // expiry, all local. A forged or expired credential costs one HMAC and never // becomes a request to meta.sr.ht or to tokens.sr.ht. bt := auth.DecodeBearerToken(presented) if bt == nil { return nil, fmt.Errorf("%w: token failed HMAC/expiry validation", ErrInvalidToken) } if bt.ClientID == bearer.TokensClientID { return resolveWorkingToken(ctx, v, presented) } return resolveMetaPAT(ctx, bt.Username, presented) } // resolveWorkingToken is the tokens.sr.ht arm. func resolveWorkingToken(ctx context.Context, v InstanceValidator, presented string) (*BearerCaller, error) { if v == nil { return nil, fmt.Errorf( "%w: this instance configures no [tokens.sr.ht] origin, so a working token cannot be verified", ErrInvalidToken) } tok, err := v.Inspect(ctx, presented) if err != nil { return nil, classifyInspect(err) } // The token names a meta.sr.ht account and nothing else; turning that into a // local row is the service's job (bearer's package doc says so, and the // tokens SPEC ch. 6 prescribes it for every service on the instance). It is // the same call the Basic path makes, through the same seam. var ac auth.AuthContext if err := meta.LookupUser(ctx, tok.Username, &ac); err != nil { // Transient: meta or the database could not answer. The credential is // good, and telling an agent to re-mint over a lookup outage is the wrong // instruction twice — it does not help, and it destroys a working token. return nil, fmt.Errorf("looking up user %q: %w", tok.Username, err) } if ac.UserID == 0 { // LookupUser answered without filling in an id. Nothing downstream can // use that: every ownership and ACL row keys on the user id, and a zero // would match the first repository whose owner id is unset. Permanent // rather than transient — retrying will not conjure the account back. return nil, fmt.Errorf("%w: working token names %q, for whom no meta id was mirrored", ErrInvalidToken, tok.Username) } ac.AuthMethod = AuthMethodInstanceToken // BearerToken and Grants are deliberately left unset. They are core-go's // OAuth fields, and filling them would subject this caller to // TokenGrantsAllow — a gate demanding "dolt.sr.ht/repos:RO", which a // tokens.sr.ht grant string can never spell. A working token is scoped by // its own vocabulary, on BearerCaller.Grants, and by core.Allowed. return &BearerCaller{ AuthContext: &ac, InstanceToken: true, Grants: tok.Grants, }, nil } // resolveMetaPAT is the meta.sr.ht arm: ResolveBasic with the token's own // username standing in for the presented one, plus the read gate. func resolveMetaPAT(ctx context.Context, username, presented string) (*BearerCaller, error) { // Passing the token's own username makes ResolveBasic's impersonation check // a tautology, which is correct here and only here: that check exists to // stop a token being used *as* another user's password, and there is no // second party's name in a bearer header to be checked against. Everything // else it does — the positive cache, the profile mirror, the revocation // check, the grant decode — is exactly what this arm needs, and is the // reason this is a call and not a copy. ac, err := ResolveBasic(ctx, username, presented) if err != nil { return nil, err } // The whole surface is a read, so the gate can be applied once here rather // than per action. It is the same check the clone path applies. if !TokenGrantsAllow(ac, core.AccessRO) { return nil, fmt.Errorf("%w: %w: token grants do not permit %s on %s repositories", ErrInvalidToken, ErrMissingGrant, core.AccessRO, RepoScope) } return &BearerCaller{AuthContext: ac, InstanceToken: false}, nil } // classifyInspect maps sr-ht-ecore's sentinels onto this package's two error // classes. It is the one place this service decides what each refusal of the // shared validator means here. // // The 401/503 split is the one that is easy to get wrong and expensive to get // wrong: an unreachable tokens.sr.ht must not read as a bad credential. "I could // not check" is not "your token is revoked", and answering 401 there would turn // a restart of a daemon deliberately kept off the hot path into every agent on // the instance being told to re-mint its credentials. // // bearer.ErrNotOurs is classified for totality and is not reachable: ResolveBearer // routes on the ClientID before Inspect is called, so a foreign token has already // gone to the meta arm. // // An unrecognised error is transient, which is the fail-closed direction here: a // sentinel this table has never seen must read as "I could not decide" — a 503 // the caller retries — never as a verdict about the credential. func classifyInspect(err error) error { switch { case errors.Is(err, bearer.ErrInvalid), errors.Is(err, bearer.ErrNotOurs), errors.Is(err, bearer.ErrRevoked): return fmt.Errorf("%w: %w", ErrInvalidToken, err) case errors.Is(err, bearer.ErrForbidden): // Not reachable through Inspect, which is not told an action; classified // so the table is total. Joined with ErrInvalidToken for the reason // ErrMissingGrant's own comment gives. return fmt.Errorf("%w: %w: %w", ErrInvalidToken, ErrMissingGrant, err) case errors.Is(err, bearer.ErrUnavailable): return fmt.Errorf("asking tokens.sr.ht whether a working token is live: %w", err) default: return fmt.Errorf("validating a tokens.sr.ht working token: %w", err) } }