~bigbes/sr-ht-dolt

6288fc1e6eb93ed5751f504c91b599b3058af361 — Eugene Blikh 5 days ago 97e0f8a
authn: accept tokens.sr.ht working tokens and PATs as bearer

The /mcp surface is bearer-only, and none of the three credential planes
this service has fits an agent. ResolveBearer adds the fourth: it decodes
the presented token once, locally, and routes on the ClientID — the only
thing that tells a tokens.sr.ht working token from a meta.sr.ht PAT, since
both are sealed with the same instance key.

A working token goes through sr-ht-ecore's validator (one copy of that
check for the whole instance) and carries its grants out on the result, so
the surface can ask Authorize where the action is known. A PAT reuses
ResolveBasic with the token's own username as the identity — there is no
presented username to compare against in a bearer header — and the same
TokenGrantsAllow read gate the clone path applies.

An instance with no [tokens.sr.ht] section passes a nil validator: meta
PATs and anonymity keep working, a working token is refused, because a
machine credential this instance cannot verify is refused rather than
guessed at. A failed credential is always a refusal and never a downgrade
to anonymous, and backend.go's two error classes are preserved so a caller
can still answer 401 against 503.

core.GrantRead is the vocabulary tokens.sr.ht deliberately does not know.
There is no dolt:write: nothing on that surface writes, and a grant nobody
checks is a promise to an operator that no code keeps.
3 files changed, 665 insertions(+), 0 deletions(-)

A authn/bearer.go
A authn/bearer_test.go
A core/grants.go
A authn/bearer.go => authn/bearer.go +303 -0
@@ 0,0 1,303 @@
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)
	}
}

A authn/bearer_test.go => authn/bearer_test.go +339 -0
@@ 0,0 1,339 @@
package authn

import (
	"context"
	"crypto/sha512"
	"errors"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

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

// forgeWorkingToken builds a token shaped exactly as tokens.sr.ht seals one:
// the same format and the same HMAC key as a meta PAT, differing only in the
// ClientID. That difference is the whole routing decision in ResolveBearer, so
// the fixture has to carry it rather than assert it.
func forgeWorkingToken(username string, expires time.Time) string {
	bt := auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(expires),
		ClientID: bearer.TokensClientID,
		Username: username,
	}
	return bt.Encode()
}

// fakeValidator stands in for sr-ht-ecore's bearer.Validator: it answers with
// whatever the test configured and records what it was asked, so that a test can
// tell "refused before the daemon" from "the daemon refused".
type fakeValidator struct {
	tok       *bearer.Token
	err       error
	inspected []string
}

func (f *fakeValidator) Inspect(ctx context.Context, presented string) (*bearer.Token, error) {
	f.inspected = append(f.inspected, presented)
	if f.err != nil {
		return nil, f.err
	}
	return f.tok, nil
}

// mustGrants parses a tokens.sr.ht grant string with the one parser that is
// allowed to read one.
func mustGrants(t *testing.T, s string) grants.Grants {
	t.Helper()
	g, err := grants.Parse(s)
	require.NoError(t, err, "grants.Parse(%q)", s)
	return g
}

func TestParseBearer(t *testing.T) {
	cases := []struct {
		name   string
		header string // "" means no Authorization header at all
		want   string
	}{
		{"absent header", "", ""},
		{"basic is not ours to reject", "Basic dXNlcjpwYXNz", ""},
		{"canonical scheme", "Bearer abc123", "abc123"},
		{"lower-case scheme (RFC 7235 is case-insensitive)", "bearer abc123", "abc123"},
		{"upper-case scheme", "BEARER abc123", "abc123"},
		{"padded value", "Bearer   abc123  ", "abc123"},
		{"scheme with no value", "Bearer", ""},
		{"scheme with only spaces", "Bearer   ", ""},
		{"bare token with no scheme", "abc123", ""},
		{"unknown scheme", "Internal abc123", ""},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			r := httptest.NewRequest(http.MethodGet, "/mcp", nil)
			if tc.header != "" {
				r.Header.Set("Authorization", tc.header)
			}
			assert.Equal(t, tc.want, ParseBearer(r))
		})
	}
}

func TestResolveBearer_WorkingToken_WithReadGrant(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
		"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
	}})
	v := &fakeValidator{tok: &bearer.Token{
		Username: "bigbes",
		Grants:   mustGrants(t, "dolt:read id:7"),
		TokenID:  7,
	}}
	token := forgeWorkingToken("bigbes", time.Now().Add(time.Hour))

	bc, err := ResolveBearer(testCtx(), v, token)
	require.NoError(t, err)
	require.NotNil(t, bc.AuthContext)
	assert.True(t, bc.InstanceToken)
	assert.Equal(t, "bigbes", bc.AuthContext.Username)
	assert.Equal(t, 1, bc.AuthContext.UserID)
	assert.Equal(t, AuthMethodInstanceToken, bc.AuthContext.AuthMethod)
	assert.NoError(t, bc.Authorize(core.GrantRead))

	// The presented string reaches the validator untouched — nothing here
	// re-encodes or trims a credential.
	assert.Equal(t, []string{token}, v.inspected)

	// A working token must not be subjected to meta's OAuth gate: it carries no
	// core-go grants and could never satisfy it.
	assert.Nil(t, bc.AuthContext.BearerToken)
	assert.True(t, TokenGrantsAllow(bc.AuthContext, core.AccessRO))
}

func TestResolveBearer_WorkingToken_WithoutReadGrant(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
		"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
	}})
	v := &fakeValidator{tok: &bearer.Token{
		Username: "bigbes",
		Grants:   mustGrants(t, "cover:read"),
	}}

	// It resolves: who the caller is does not depend on what they may do. The
	// gate belongs to the surface, but the answer belongs to this package.
	bc, err := ResolveBearer(testCtx(), v, forgeWorkingToken("bigbes", time.Now().Add(time.Hour)))
	require.NoError(t, err)
	assert.Equal(t, "bigbes", bc.AuthContext.Username)

	err = bc.Authorize(core.GrantRead)
	require.Error(t, err)
	assert.ErrorIs(t, err, ErrMissingGrant)
	assert.NotErrorIs(t, err, ErrInvalidToken, "a narrow grant is a 403, not a bad credential")
}

func TestResolveBearer_WorkingToken_UniversalGrant(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
		"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
	}})
	v := &fakeValidator{tok: &bearer.Token{
		Username: "bigbes",
		Grants:   mustGrants(t, "*"),
	}}

	bc, err := ResolveBearer(testCtx(), v, forgeWorkingToken("bigbes", time.Now().Add(time.Hour)))
	require.NoError(t, err)
	assert.NoError(t, bc.Authorize(core.GrantRead))
}

func TestResolveBearer_WorkingToken_ValidatorRefusals(t *testing.T) {
	cases := []struct {
		name string
		err  error
		// permanent: wraps ErrInvalidToken (401); otherwise transient (503).
		permanent bool
		grant     bool
	}{
		{"forged or expired", bearer.ErrInvalid, true, false},
		{"revoked", bearer.ErrRevoked, true, false},
		{"foreign issuer", bearer.ErrNotOurs, true, false},
		{"missing grant", bearer.ErrForbidden, true, true},
		{"daemon unreachable", bearer.ErrUnavailable, false, false},
		{"a sentinel this table has never seen", errBackendDown, false, false},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
				"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
			}})
			v := &fakeValidator{err: tc.err}

			bc, err := ResolveBearer(testCtx(), v, forgeWorkingToken("bigbes", time.Now().Add(time.Hour)))
			require.Error(t, err)
			assert.Nil(t, bc, "a refusal is never a caller")
			assert.ErrorIs(t, err, tc.err, "the underlying refusal must stay readable")
			if tc.permanent {
				assert.ErrorIs(t, err, ErrInvalidToken)
			} else {
				assert.NotErrorIs(t, err, ErrInvalidToken,
					"an unreachable or unreadable answer is transient, never a credential verdict")
			}
			assert.Equal(t, tc.grant, errors.Is(err, ErrMissingGrant))
		})
	}
}

func TestResolveBearer_WorkingToken_NoValidatorIsRefused(t *testing.T) {
	sb := &stubBackend{users: map[string]auth.AuthContext{
		"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
	}}
	withStubBackend(t, sb)

	t.Run("a working token cannot be verified", func(t *testing.T) {
		_, err := ResolveBearer(testCtx(), nil, forgeWorkingToken("bigbes", time.Now().Add(time.Hour)))
		require.Error(t, err)
		assert.ErrorIs(t, err, ErrInvalidToken)
		assert.Empty(t, sb.lookedUp, "an unverifiable credential must not reach the backend")
	})

	t.Run("a meta PAT still resolves", func(t *testing.T) {
		pat := forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(time.Hour))
		bc, err := ResolveBearer(testCtx(), nil, pat)
		require.NoError(t, err)
		assert.False(t, bc.InstanceToken)
		assert.Equal(t, "bigbes", bc.AuthContext.Username)
	})
}

func TestResolveBearer_WorkingToken_LookupFailures(t *testing.T) {
	t.Run("backend down is transient", func(t *testing.T) {
		withStubBackend(t, &stubBackend{lookupErr: errBackendDown})
		v := &fakeValidator{tok: &bearer.Token{Username: "bigbes", Grants: mustGrants(t, "dolt:read")}}

		_, err := ResolveBearer(testCtx(), v, forgeWorkingToken("bigbes", time.Now().Add(time.Hour)))
		require.Error(t, err)
		assert.NotErrorIs(t, err, ErrInvalidToken)
	})

	t.Run("no mirrored user id is permanent", func(t *testing.T) {
		// LookupUser answers, but with no id: nothing downstream can key on that.
		withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
			"ghost": {Username: "ghost", UserType: auth.USER_TYPE_USER},
		}})
		v := &fakeValidator{tok: &bearer.Token{Username: "ghost", Grants: mustGrants(t, "dolt:read")}}

		_, err := ResolveBearer(testCtx(), v, forgeWorkingToken("ghost", time.Now().Add(time.Hour)))
		require.Error(t, err)
		assert.ErrorIs(t, err, ErrInvalidToken)
	})
}

func TestResolveBearer_MetaPAT_SufficientGrants(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
		"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
	}})
	v := &fakeValidator{err: errBackendDown} // must not be consulted at all
	pat := forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(time.Hour))

	bc, err := ResolveBearer(testCtx(), v, pat)
	require.NoError(t, err)
	assert.False(t, bc.InstanceToken)
	assert.Equal(t, "bigbes", bc.AuthContext.Username)
	assert.Equal(t, auth.AUTH_OAUTH2, bc.AuthContext.AuthMethod)
	assert.Equal(t, sha512.Sum512([]byte(pat)), bc.AuthContext.TokenHash)
	assert.Empty(t, v.inspected, "a PAT is never handed to the tokens.sr.ht validator")

	// The tokens.sr.ht vocabulary does not apply to a PAT: it was scoped at
	// resolve time by the OAuth gate instead.
	assert.NoError(t, bc.Authorize(core.GrantRead))
}

func TestResolveBearer_MetaPAT_UniversalGrantsAndTilde(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
		"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
	}})
	// An empty grant string is a universal personal token, and the token's own
	// username is the identity — including its "~" sigil, which must not become
	// a mismatch against itself.
	pat := forgePAT("~BigBes", "", time.Now().Add(time.Hour))

	bc, err := ResolveBearer(testCtx(), nil, pat)
	require.NoError(t, err)
	assert.Equal(t, 1, bc.AuthContext.UserID)
}

func TestResolveBearer_MetaPAT_InsufficientGrants(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
		"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
	}})
	pat := forgePAT("bigbes", "git.sr.ht/repos:RW", time.Now().Add(time.Hour))

	bc, err := ResolveBearer(testCtx(), nil, pat)
	require.Error(t, err)
	assert.Nil(t, bc)
	assert.ErrorIs(t, err, ErrMissingGrant, "a 403: the token is good, its scope is not")
	assert.ErrorIs(t, err, ErrInvalidToken,
		"and permanent, so a caller that knows only the two classes still answers 401")
}

func TestResolveBearer_MetaPAT_Revoked(t *testing.T) {
	pat := forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(time.Hour))
	withStubBackend(t, &stubBackend{
		users:   map[string]auth.AuthContext{"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER)},
		revoked: map[[64]byte]bool{sha512.Sum512([]byte(pat)): true},
	})

	_, err := ResolveBearer(testCtx(), nil, pat)
	require.Error(t, err)
	assert.ErrorIs(t, err, ErrInvalidToken)
}

func TestResolveBearer_MetaPAT_BackendDownIsTransient(t *testing.T) {
	withStubBackend(t, &stubBackend{
		users:     map[string]auth.AuthContext{"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER)},
		revokeErr: errBackendDown,
	})
	pat := forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(time.Hour))

	_, err := ResolveBearer(testCtx(), nil, pat)
	require.Error(t, err)
	assert.NotErrorIs(t, err, ErrInvalidToken,
		"meta being unreachable is a 503, not a verdict on the credential")
}

func TestResolveBearer_UnusableCredentials(t *testing.T) {
	// None of these ever reaches a backend or a validator: they fail on the
	// local HMAC/expiry check, which is why that step runs first.
	cases := []struct {
		name      string
		presented string
	}{
		{"empty (the caller lost its own header)", ""},
		{"garbage", "this-is-not-a-token"},
		{"expired meta PAT", forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(-time.Minute))},
		{"expired working token", forgeWorkingToken("bigbes", time.Now().Add(-time.Minute))},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			sb := &stubBackend{users: map[string]auth.AuthContext{
				"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
			}}
			withStubBackend(t, sb)
			v := &fakeValidator{tok: &bearer.Token{Username: "bigbes", Grants: mustGrants(t, "*")}}

			bc, err := ResolveBearer(testCtx(), v, tc.presented)
			require.Error(t, err)
			assert.Nil(t, bc, "a failed credential is a refusal, never a downgrade to anonymous")
			assert.ErrorIs(t, err, ErrInvalidToken)
			assert.Empty(t, v.inspected)
			assert.Empty(t, sb.lookedUp)
		})
	}
}

A core/grants.go => core/grants.go +23 -0
@@ 0,0 1,23 @@
package core

// The tokens.sr.ht grant vocabulary of dolt.sr.ht.
//
// tokens.sr.ht deliberately knows no service's vocabulary (tokens SPEC ch. 3):
// it seals whatever grant string it is asked for and passes it through, and the
// service that accepts the token is the one that decides what a member means.
// So the vocabulary is declared here, in the pure domain package, next to the
// access matrix it complements — and not in authn/, which validates a
// credential, nor in the surface, which would then be the only place that knows
// what it is asking for.

// GrantRead is the tokens.sr.ht grant an instance working token must carry to
// read anything through /mcp.
//
// There is deliberately no dolt:write. Nothing on that surface writes — it has
// no SQL tool and no mutation by design (docs/DESIGN.mcp.md §2) — and a grant
// nobody checks is a promise to an operator that no code keeps: a token minted
// with a narrow "dolt:read" would be indistinguishable in effect from one minted
// with "dolt:write", and the operator who chose the first would believe in a
// boundary that does not exist. When something on this service does write
// through a bearer credential, the constant is added together with the check.
const GrantRead = "dolt:read"