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 <token>" 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)
}
}