From 6288fc1e6eb93ed5751f504c91b599b3058af361 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Wed, 12 Aug 2026 23:25:32 +0300 Subject: [PATCH] authn: accept tokens.sr.ht working tokens and PATs as bearer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- authn/bearer.go | 303 ++++++++++++++++++++++++++++++++++++++ authn/bearer_test.go | 339 +++++++++++++++++++++++++++++++++++++++++++ core/grants.go | 23 +++ 3 files changed, 665 insertions(+) create mode 100644 authn/bearer.go create mode 100644 authn/bearer_test.go create mode 100644 core/grants.go diff --git a/authn/bearer.go b/authn/bearer.go new file mode 100644 index 0000000000000000000000000000000000000000..b06b69905cf4f9054d8eb378fca23310bcc2fc2a --- /dev/null +++ b/authn/bearer.go @@ -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 " 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) + } +} diff --git a/authn/bearer_test.go b/authn/bearer_test.go new file mode 100644 index 0000000000000000000000000000000000000000..518c4f4f061bffe22977b495cd33dd969014a695 --- /dev/null +++ b/authn/bearer_test.go @@ -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) + }) + } +} diff --git a/core/grants.go b/core/grants.go new file mode 100644 index 0000000000000000000000000000000000000000..b78eceebf873fa0933018b239ac7eb3d4a24819a --- /dev/null +++ b/core/grants.go @@ -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"