@@ 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)
+ }
+}
@@ 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)
+ })
+ }
+}