~bigbes/sourcehut-dolt

5e555bac101b4a9a43a25941bc8c0fd9f8aecd03 — Eugene Blikh 30 days ago 6efd274
authn: cookie, PAT, and dolt-JWT caller resolution

Add the authn package resolving the SourceHut caller across dolt.sr.ht's
three auth flows, producing core-go *auth.AuthContext values mapped onto the
pure core.Caller domain type:

- ctx.go: WithCaller/CallerFromContext (nil for anonymous, never panics) and
  AsCoreCaller (maps UserType, derives Suspended).
- cookie.go: OptionalCookieMiddleware, the never-rejecting unified-login
  cookie reader (fernet decrypt -> {name} -> LookupUser); any failure degrades
  to anonymous so public browsing keeps working.
- token.go: ResolveBasic, the meta personal-access-token trio (offline
  DecodeBearerToken -> username match -> LookupUser + revocation) with a 60s
  positive cache keyed by sha512(password); TokenGrantsAllow gates
  dolt.sr.ht/repos RO/RW grants (empty grants pass).
- jwt.go: ResolveDoltJWT, EdDSA JWS verification for dolt keypair auth (kid ->
  KeyStore pubkey, alg=EdDSA, aud/exp/sub checked, iss ignored), reusing
  dolt's creds.PubKeyToKIDStr for the kid<->pubkey integrity check.
- grpc.go: ResolveGRPCAuth dispatching Basic/Bearer/anonymous.

Meta lookup + revocation sit behind the MetaBackend interface and JWT keys
behind the KeyStore interface (implemented later by db/), so tests forge
cookies (fernet), PATs (BearerToken.Encode) and real Ed25519 JWTs against
in-memory stubs with no network or Postgres.
A authn/authn_test.go => authn/authn_test.go +187 -0
@@ 0,0 1,187 @@
package authn

import (
	"context"
	"crypto/ed25519"
	"encoding/base64"
	"encoding/json"
	"os"
	"testing"
	"time"

	"git.sr.ht/~sircmpwn/core-go/auth"
	"git.sr.ht/~sircmpwn/core-go/config"
	"git.sr.ht/~sircmpwn/core-go/crypto"
	"github.com/dolthub/dolt/go/libraries/doltcore/creds"
	"github.com/fernet/fernet-go"
	"github.com/vaughan0/go-ini"
	jose "gopkg.in/go-jose/go-jose.v2"
	"gopkg.in/go-jose/go-jose.v2/jwt"
)

// TestMain synthesizes an in-memory instance config (random fernet network key +
// random ed25519 webhooks seed) and runs crypto.InitCrypto once, so that cookie
// encryption (crypto.Encrypt / DecryptWithoutExpiration) and bearer-token HMAC
// (auth.BearerToken.Encode / auth.DecodeBearerToken) share a keyset across the
// whole package's tests. No network, no Postgres.
func TestMain(m *testing.M) {
	var fk fernet.Key
	if err := fk.Generate(); err != nil {
		panic(err)
	}

	seed := make([]byte, ed25519.SeedSize)
	// Deterministic non-zero seed is fine; these keys never leave the test.
	for i := range seed {
		seed[i] = byte(i + 1)
	}

	conf := ini.File{
		"sr.ht":    ini.Section{"network-key": fk.Encode()},
		"webhooks": ini.Section{"private-key": base64.StdEncoding.EncodeToString(seed)},
	}
	crypto.InitCrypto(conf)

	os.Exit(m.Run())
}

// testCtx returns a context carrying the config/service so that
// auth.DecodeGrants (which reads config.ServiceName) works in tests.
func testCtx() context.Context {
	return config.Context(context.Background(), ini.File{}, "dolt.sr.ht")
}

// stubBackend is an in-memory MetaBackend: LookupUser fills from a fixed table,
// IsRevoked consults a set of revoked hashes. Both can be told to fail
// (transient error) to exercise the temporary-error path.
type stubBackend struct {
	users     map[string]auth.AuthContext // keyed by lowercased username (no "~")
	revoked   map[[64]byte]bool
	lookupErr error
	revokeErr error
}

func (s *stubBackend) LookupUser(ctx context.Context, username string, out *auth.AuthContext) error {
	if s.lookupErr != nil {
		return s.lookupErr
	}
	u, ok := s.users[normalize(username)]
	if !ok {
		return errUnknownUser
	}
	*out = u
	return nil
}

func (s *stubBackend) IsRevoked(ctx context.Context, username string, hash [64]byte, clientID string) (bool, error) {
	if s.revokeErr != nil {
		return false, s.revokeErr
	}
	return s.revoked[hash], nil
}

func normalize(username string) string {
	if len(username) > 0 && username[0] == '~' {
		username = username[1:]
	}
	return toLower(username)
}

func toLower(s string) string {
	b := []byte(s)
	for i, c := range b {
		if c >= 'A' && c <= 'Z' {
			b[i] = c + ('a' - 'A')
		}
	}
	return string(b)
}

var errUnknownUser = errTest("unknown user")
var errBackendDown = errTest("backend down")

type errTest string

func (e errTest) Error() string { return string(e) }

// withStubBackend installs a stub MetaBackend for the duration of a test and
// resets the token cache and clock, restoring everything afterwards.
func withStubBackend(t *testing.T, s MetaBackend) {
	t.Helper()
	prev := meta
	meta = s
	resetTokenCache()
	prevNow := nowFn
	t.Cleanup(func() {
		meta = prev
		nowFn = prevNow
		resetTokenCache()
	})
}

func resetTokenCache() {
	tokenCacheMu.Lock()
	tokenCache = map[[64]byte]cacheEntry{}
	tokenCacheMu.Unlock()
}

func sampleUser(id int, username, userType string) auth.AuthContext {
	notice := "suspended for testing"
	ac := auth.AuthContext{
		UserID:   id,
		Username: username,
		Email:    username + "@example.com",
		UserType: userType,
	}
	if userType == auth.USER_TYPE_SUSPENDED {
		ac.SuspensionNotice = &notice
	}
	return ac
}

// forgeCookie fernet-encrypts a unified-login cookie payload for username.
func forgeCookie(t *testing.T, username string) string {
	t.Helper()
	payload, err := json.Marshal(auth.AuthCookie{Name: username})
	if err != nil {
		t.Fatal(err)
	}
	return string(crypto.Encrypt(payload))
}

// forgePAT builds a valid meta personal access token for username with the
// given grants and expiry, HMAC-signed with the test keyset.
func forgePAT(username, grants string, expires time.Time) string {
	bt := auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(expires),
		Grants:   grants,
		Username: username,
	}
	return bt.Encode()
}

// mintDoltJWT builds an EdDSA JWS exactly as dolt's creds.RPCCreds does: kid +
// dolt_token_version headers, and aud/iss/sub/exp claims.
func mintDoltJWT(t *testing.T, priv ed25519.PrivateKey, kid, aud, iss, sub string, expiry time.Time) string {
	t.Helper()
	signingKey := jose.SigningKey{Algorithm: jose.EdDSA, Key: priv}
	opts := &jose.SignerOptions{ExtraHeaders: map[jose.HeaderKey]interface{}{
		jose.HeaderKey(creds.JWTKIDHeader):           kid,
		jose.HeaderKey(creds.DoltTokenVersionHeader): "2023.01",
	}}
	signer, err := jose.NewSigner(signingKey, opts)
	if err != nil {
		t.Fatal(err)
	}
	raw, err := jwt.Signed(signer).Claims(jwt.Claims{
		Audience: jwt.Audience{aud},
		Issuer:   iss,
		Subject:  sub,
		Expiry:   jwt.NewNumericDate(expiry),
	}).CompactSerialize()
	if err != nil {
		t.Fatal(err)
	}
	return raw
}

A authn/backend.go => authn/backend.go +59 -0
@@ 0,0 1,59 @@
package authn

import (
	"context"
	"errors"
	"strings"

	"git.sr.ht/~sircmpwn/core-go/auth"
)

// ErrInvalidToken is the sentinel wrapped by every *permanent* credential
// rejection: a malformed/expired/forged token, a username mismatch, or a
// revoked token. Callers (e.g. the remotesapi interceptors) map errors.Is(err,
// ErrInvalidToken) to an authentication failure (HTTP 401 / gRPC
// Unauthenticated). A resolution error that does NOT wrap ErrInvalidToken is a
// *transient* backend failure (meta.sr.ht unreachable, database error) and
// should be surfaced as "try again later" (HTTP 500 / gRPC Unavailable), never
// as a hard credential rejection — this mirrors core-go's auth.OAuth2, which
// distinguishes its temporary-error path from an invalid-token path.
var ErrInvalidToken = errors.New("authn: invalid or expired credentials")

// MetaBackend abstracts the two meta.sr.ht-backed lookups the resolvers need:
// mirroring a user into the local "user" table and checking whether a token has
// been revoked. The production implementation (coreMetaBackend) delegates to
// core-go, which reads the database and config from the request context; tests
// swap in an in-memory stub so they need neither Postgres nor the network.
type MetaBackend interface {
	// LookupUser fills out with the user identified by username, mirroring the
	// profile from meta.sr.ht into the local database on first sight. Mirrors
	// core-go's auth.LookupUser semantics.
	LookupUser(ctx context.Context, username string, out *auth.AuthContext) error
	// IsRevoked reports whether the personal access token with the given sha512
	// hash (and, for OAuth clients, clientID) has been revoked on meta.sr.ht.
	// Mirrors core-go's auth.LookupTokenRevocation.
	IsRevoked(ctx context.Context, username string, hash [64]byte, clientID string) (bool, error)
}

// coreMetaBackend is the production MetaBackend: it forwards to core-go, whose
// implementations read database.ForContext / config.ServiceName from ctx and
// (on a local miss) fetch the profile from meta.sr.ht over internal GraphQL.
type coreMetaBackend struct{}

func (coreMetaBackend) LookupUser(ctx context.Context, username string, out *auth.AuthContext) error {
	return auth.LookupUser(ctx, username, out)
}

func (coreMetaBackend) IsRevoked(ctx context.Context, username string, hash [64]byte, clientID string) (bool, error) {
	return auth.LookupTokenRevocation(ctx, username, hash, clientID)
}

// meta is the backend used by the resolution functions. It defaults to the real
// meta.sr.ht implementation; tests reassign it (white-box) and restore it.
var meta MetaBackend = coreMetaBackend{}

// equalUsername reports whether two SourceHut usernames refer to the same user,
// ignoring a leading "~" (the canonical-name sigil) and ASCII case.
func equalUsername(a, b string) bool {
	return strings.EqualFold(strings.TrimPrefix(a, "~"), strings.TrimPrefix(b, "~"))
}

A authn/cookie.go => authn/cookie.go +75 -0
@@ 0,0 1,75 @@
package authn

import (
	"encoding/json"
	"log"
	"net/http"

	"git.sr.ht/~sircmpwn/core-go/auth"
	"git.sr.ht/~sircmpwn/core-go/crypto"
)

// CookieName is the unified-login cookie shared across every SourceHut service.
const CookieName = "sr.ht.unified-login.v1"

// OptionalCookieMiddleware reads the sr.ht.unified-login.v1 cookie and, when it
// is present and valid, attaches the resolved caller to the request context
// (retrievable with CallerFromContext). It NEVER rejects a request: a missing,
// malformed, undecryptable, or unresolvable cookie leaves the request
// anonymous. This is what allows public browsing and public clones to work
// without credentials — unlike core-go's auth.Middleware, which 401s any
// request lacking a cookie or Authorization header.
//
// It replicates the logic of core-go's unexported cookieAuth: decrypt the
// fernet-sealed cookie with crypto.DecryptWithoutExpiration, decode the
// {"name": ...} payload, and resolve the user via the meta backend (core-go's
// auth.LookupUser path). Suspended users are resolved normally; the suspension
// flag is carried on the caller (via AsCoreCaller) and gates writes at the
// access-control layer rather than being rejected here.
//
// Requires crypto.InitCrypto to have run (server.New does this at startup) and,
// for the user lookup, config.Middleware + database.Middleware installed
// upstream so the context carries the config and database.
func OptionalCookieMiddleware() func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if ac := resolveCookie(r); ac != nil {
				r = r.WithContext(WithCaller(r.Context(), ac))
			}
			next.ServeHTTP(w, r)
		})
	}
}

// resolveCookie returns the caller authenticated by the request's unified-login
// cookie, or nil if there is no cookie or it cannot be resolved for any reason.
// Every failure path returns nil (anonymous) — none is fatal.
func resolveCookie(r *http.Request) *auth.AuthContext {
	cookie, err := r.Cookie(CookieName)
	if err != nil {
		return nil // no cookie: anonymous
	}

	payload := crypto.DecryptWithoutExpiration([]byte(cookie.Value))
	if payload == nil {
		return nil // bad/forged/undecryptable cookie: anonymous
	}

	var authCookie auth.AuthCookie
	if err := json.Unmarshal(payload, &authCookie); err != nil {
		return nil // malformed payload: anonymous
	}
	if authCookie.Name == "" {
		return nil // no username in payload: anonymous
	}

	var ac auth.AuthContext
	if err := meta.LookupUser(r.Context(), authCookie.Name, &ac); err != nil {
		// meta/database unreachable or unknown user: degrade to anonymous
		// rather than failing the request (browsing must keep working).
		log.Printf("authn: cookie LookupUser(%q): %v", authCookie.Name, err)
		return nil
	}
	ac.AuthMethod = auth.AUTH_COOKIE
	return &ac
}

A authn/cookie_test.go => authn/cookie_test.go +129 -0
@@ 0,0 1,129 @@
package authn

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"git.sr.ht/~sircmpwn/core-go/auth"
)

// captureHandler records the caller present on the request context when reached.
func captureHandler(dst **auth.AuthContext, reached *bool) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		*reached = true
		*dst = CallerFromContext(r.Context())
		w.WriteHeader(http.StatusOK)
	})
}

func runCookieMiddleware(t *testing.T, cookie *http.Cookie) (*auth.AuthContext, int) {
	t.Helper()
	var got *auth.AuthContext
	var reached bool
	h := OptionalCookieMiddleware()(captureHandler(&got, &reached))

	req := httptest.NewRequest(http.MethodGet, "/", nil)
	if cookie != nil {
		req.AddCookie(cookie)
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)

	if !reached {
		t.Fatal("middleware must always call next (never rejects)")
	}
	return got, rec.Code
}

func TestOptionalCookieMiddleware_NoCookie(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{}})
	got, code := runCookieMiddleware(t, nil)
	if got != nil {
		t.Fatalf("expected anonymous, got %+v", got)
	}
	if code != http.StatusOK {
		t.Fatalf("expected 200, got %d", code)
	}
}

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

	got, code := runCookieMiddleware(t, &http.Cookie{
		Name:  CookieName,
		Value: forgeCookie(t, "bigbes"),
	})
	if code != http.StatusOK {
		t.Fatalf("expected 200, got %d", code)
	}
	if got == nil {
		t.Fatal("expected an authenticated caller")
	}
	if got.Username != "bigbes" || got.UserID != 1 {
		t.Fatalf("wrong caller resolved: %+v", got)
	}
	if got.AuthMethod != auth.AUTH_COOKIE {
		t.Fatalf("AuthMethod = %q, want %q", got.AuthMethod, auth.AUTH_COOKIE)
	}
}

func TestOptionalCookieMiddleware_SuspendedStillResolves(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
		"susp": sampleUser(2, "susp", auth.USER_TYPE_SUSPENDED),
	}})
	got, _ := runCookieMiddleware(t, &http.Cookie{
		Name:  CookieName,
		Value: forgeCookie(t, "susp"),
	})
	if got == nil {
		t.Fatal("suspended users must still resolve (reads are allowed)")
	}
	if !AsCoreCaller(got).Suspended {
		t.Fatal("resolved caller must be flagged suspended")
	}
}

func TestOptionalCookieMiddleware_GarbageCookie(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{}})
	got, code := runCookieMiddleware(t, &http.Cookie{
		Name:  CookieName,
		Value: "not-a-valid-fernet-token",
	})
	if got != nil {
		t.Fatalf("garbage cookie must degrade to anonymous, got %+v", got)
	}
	if code != http.StatusOK {
		t.Fatalf("must not reject: expected 200, got %d", code)
	}
}

func TestOptionalCookieMiddleware_UnknownUser(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{}})
	got, code := runCookieMiddleware(t, &http.Cookie{
		Name:  CookieName,
		Value: forgeCookie(t, "ghost"),
	})
	if got != nil {
		t.Fatalf("unknown user must degrade to anonymous, got %+v", got)
	}
	if code != http.StatusOK {
		t.Fatalf("must not reject: expected 200, got %d", code)
	}
}

func TestOptionalCookieMiddleware_BackendDownIsAnonymous(t *testing.T) {
	withStubBackend(t, &stubBackend{lookupErr: errBackendDown})
	got, code := runCookieMiddleware(t, &http.Cookie{
		Name:  CookieName,
		Value: forgeCookie(t, "bigbes"),
	})
	if got != nil {
		t.Fatalf("backend failure must degrade to anonymous, got %+v", got)
	}
	if code != http.StatusOK {
		t.Fatalf("must not 500: expected 200, got %d", code)
	}
}

A authn/ctx.go => authn/ctx.go +69 -0
@@ 0,0 1,69 @@
// Package authn resolves the SourceHut caller for a dolt.sr.ht request across
// the three authentication flows the service accepts:
//
//   - the unified-login cookie (web UI), via OptionalCookieMiddleware;
//   - a meta.sr.ht personal access token (dolt clone/push --user + Basic auth),
//     via ResolveBasic;
//   - a dolt Ed25519 keypair (dolt login / Bearer EdDSA JWT), via ResolveDoltJWT.
//
// It reuses core-go's token/cookie primitives (auth.DecodeBearerToken,
// auth.LookupUser, auth.LookupTokenRevocation, crypto.DecryptWithoutExpiration)
// and dolt's creds.PubKeyToKIDStr for key-id derivation, and produces
// *auth.AuthContext values that AsCoreCaller maps onto the pure core.Caller
// domain type for the access-control matrix in package core.
//
// Production wiring: the cookie middleware and the resolvers read the meta.sr.ht
// user database and config from the request context, so the caller must install
// config.Middleware and database.Middleware upstream (see cmd/doltsrht). The
// meta-lookup and token-revocation calls are funnelled through the package-level
// MetaBackend so tests can stub them without a database or network.
package authn

import (
	"context"

	"git.sr.ht/~sircmpwn/core-go/auth"

	"go.bigb.es/sourcehut-dolt/core"
)

// AuthMethodDoltKey labels an AuthContext resolved from a dolt Ed25519 keypair
// JWT. core-go has no such method; we never call auth.AuthContext.Access (which
// would panic on an unknown method) — access is decided by core.Allowed — so a
// private label is safe and keeps dolt-key auth distinguishable from OAuth2.
const AuthMethodDoltKey = "DOLT_KEY"

type contextKey struct{ name string }

var callerCtxKey = &contextKey{"authn.caller"}

// WithCaller returns a copy of ctx carrying the resolved caller. A nil ac is
// stored as-is and reads back as anonymous via CallerFromContext.
func WithCaller(ctx context.Context, ac *auth.AuthContext) context.Context {
	return context.WithValue(ctx, callerCtxKey, ac)
}

// CallerFromContext returns the caller stored by WithCaller, or nil for an
// anonymous request. Unlike core-go's auth.ForContext it never panics on a
// missing value: an unauthenticated request is a normal, expected state for
// dolt.sr.ht (public browsing and public clones).
func CallerFromContext(ctx context.Context) *auth.AuthContext {
	ac, _ := ctx.Value(callerCtxKey).(*auth.AuthContext)
	return ac
}

// AsCoreCaller maps a resolved *auth.AuthContext onto the pure core.Caller
// domain type consumed by core.Allowed. A nil ac (anonymous) maps to a nil
// caller. Suspended is derived from the meta UserType, which independently
// gates every write operation.
func AsCoreCaller(ac *auth.AuthContext) *core.Caller {
	if ac == nil {
		return nil
	}
	return &core.Caller{
		UserID:    ac.UserID,
		Username:  ac.Username,
		UserType:  core.UserType(ac.UserType),
		Suspended: ac.UserType == auth.USER_TYPE_SUSPENDED,
	}
}

A authn/ctx_test.go => authn/ctx_test.go +63 -0
@@ 0,0 1,63 @@
package authn

import (
	"context"
	"testing"

	"git.sr.ht/~sircmpwn/core-go/auth"

	"go.bigb.es/sourcehut-dolt/core"
)

func TestCallerFromContext_Anonymous(t *testing.T) {
	if got := CallerFromContext(context.Background()); got != nil {
		t.Fatalf("expected nil caller for empty context, got %+v", got)
	}
}

func TestWithCallerRoundTrip(t *testing.T) {
	ac := &auth.AuthContext{UserID: 7, Username: "bigbes"}
	ctx := WithCaller(context.Background(), ac)
	got := CallerFromContext(ctx)
	if got != ac {
		t.Fatalf("expected to retrieve the stored caller, got %+v", got)
	}
}

func TestAsCoreCaller_Nil(t *testing.T) {
	if got := AsCoreCaller(nil); got != nil {
		t.Fatalf("nil auth context must map to nil (anonymous) caller, got %+v", got)
	}
}

func TestAsCoreCaller_Mapping(t *testing.T) {
	cases := []struct {
		name          string
		userType      string
		wantType      core.UserType
		wantSuspended bool
	}{
		{"user", auth.USER_TYPE_USER, core.UserTypeUser, false},
		{"admin", auth.USER_TYPE_ADMIN, core.UserTypeAdmin, false},
		{"pending", auth.USER_TYPE_PENDING, core.UserTypePending, false},
		{"suspended", auth.USER_TYPE_SUSPENDED, core.UserTypeSuspended, true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			ac := &auth.AuthContext{UserID: 42, Username: "u", UserType: tc.userType}
			got := AsCoreCaller(ac)
			if got == nil {
				t.Fatal("expected non-nil caller")
			}
			if got.UserID != 42 || got.Username != "u" {
				t.Fatalf("identity not carried through: %+v", got)
			}
			if got.UserType != tc.wantType {
				t.Fatalf("UserType = %q, want %q", got.UserType, tc.wantType)
			}
			if got.Suspended != tc.wantSuspended {
				t.Fatalf("Suspended = %v, want %v", got.Suspended, tc.wantSuspended)
			}
		})
	}
}

A authn/grpc.go => authn/grpc.go +49 -0
@@ 0,0 1,49 @@
package authn

import (
	"context"
	"encoding/base64"
	"fmt"
	"strings"

	"git.sr.ht/~sircmpwn/core-go/auth"
)

// ResolveGRPCAuth resolves the caller from a remotesapi gRPC "authorization"
// metadata header, dispatching on its scheme exactly as the dolt client sends
// it (see dolt's grpc_dial_provider):
//
//   - "Basic <base64(user:pass)>" → ResolveBasic (meta personal access token);
//   - "Bearer <jwt>"              → ResolveDoltJWT (dolt keypair EdDSA JWT);
//   - empty                       → (nil, nil): an anonymous request, which is
//     valid for public clones.
//
// A malformed or unsupported header is a permanent rejection wrapping
// ErrInvalidToken. keys is only consulted for the Bearer path.
func ResolveGRPCAuth(ctx context.Context, authorizationHeader, expectedAud string, keys KeyStore) (*auth.AuthContext, error) {
	if authorizationHeader == "" {
		return nil, nil // anonymous
	}

	scheme, value, ok := strings.Cut(authorizationHeader, " ")
	if !ok || value == "" {
		return nil, fmt.Errorf("%w: malformed authorization header", ErrInvalidToken)
	}

	switch strings.ToLower(scheme) {
	case "basic":
		raw, err := base64.StdEncoding.DecodeString(value)
		if err != nil {
			return nil, fmt.Errorf("%w: Basic credentials are not valid base64: %v", ErrInvalidToken, err)
		}
		username, password, ok := strings.Cut(string(raw), ":")
		if !ok {
			return nil, fmt.Errorf("%w: Basic credentials missing ':' separator", ErrInvalidToken)
		}
		return ResolveBasic(ctx, username, password)
	case "bearer":
		return ResolveDoltJWT(ctx, value, expectedAud, keys)
	default:
		return nil, fmt.Errorf("%w: unsupported authorization scheme %q", ErrInvalidToken, scheme)
	}
}

A authn/grpc_test.go => authn/grpc_test.go +91 -0
@@ 0,0 1,91 @@
package authn

import (
	"encoding/base64"
	"errors"
	"testing"
	"time"

	"git.sr.ht/~sircmpwn/core-go/auth"
	"github.com/dolthub/dolt/go/libraries/doltcore/creds"
)

func basicHeader(user, pass string) string {
	return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
}

func TestResolveGRPCAuth_Empty(t *testing.T) {
	withStubBackend(t, &stubBackend{})
	ac, err := ResolveGRPCAuth(testCtx(), "", testAud, &stubKeyStore{})
	if err != nil {
		t.Fatalf("empty header must be anonymous (no error), got %v", err)
	}
	if ac != nil {
		t.Fatalf("empty header must resolve to nil caller, got %+v", ac)
	}
}

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

	ac, err := ResolveGRPCAuth(testCtx(), basicHeader("bigbes", pat), testAud, &stubKeyStore{})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if ac == nil || ac.Username != "bigbes" {
		t.Fatalf("expected bigbes, got %+v", ac)
	}
}

func TestResolveGRPCAuth_Bearer(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
		"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
	}})
	priv, kid, ks := doltKeypair(t, "bigbes")
	raw := mintDoltJWT(t, priv, kid, testAud, creds.ClientIssuer, validSub(kid), time.Now().Add(30*time.Second))

	ac, err := ResolveGRPCAuth(testCtx(), "Bearer "+raw, testAud, ks)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if ac == nil || ac.Username != "bigbes" {
		t.Fatalf("expected bigbes, got %+v", ac)
	}
	if ac.AuthMethod != AuthMethodDoltKey {
		t.Fatalf("AuthMethod = %q, want %q", ac.AuthMethod, AuthMethodDoltKey)
	}
}

func TestResolveGRPCAuth_SchemeIsCaseInsensitive(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
		"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
	}})
	pat := forgePAT("bigbes", "", time.Now().Add(time.Hour))
	header := "basic " + base64.StdEncoding.EncodeToString([]byte("bigbes:"+pat))

	if _, err := ResolveGRPCAuth(testCtx(), header, testAud, &stubKeyStore{}); err != nil {
		t.Fatalf("lowercase scheme must work, got %v", err)
	}
}

func TestResolveGRPCAuth_Malformed(t *testing.T) {
	withStubBackend(t, &stubBackend{})
	cases := map[string]string{
		"no space":        "Basicdeadbeef",
		"bad base64":      "Basic !!!not-base64!!!",
		"no colon":        "Basic " + base64.StdEncoding.EncodeToString([]byte("nocolon")),
		"unknown scheme":  "Digest abcdef",
		"scheme no value": "Bearer ",
	}
	for name, header := range cases {
		t.Run(name, func(t *testing.T) {
			_, err := ResolveGRPCAuth(testCtx(), header, testAud, &stubKeyStore{})
			if !errors.Is(err, ErrInvalidToken) {
				t.Fatalf("expected ErrInvalidToken, got %v", err)
			}
		})
	}
}

A authn/jwt.go => authn/jwt.go +116 -0
@@ 0,0 1,116 @@
package authn

import (
	"context"
	"crypto/ed25519"
	"crypto/subtle"
	"fmt"
	"log"
	"time"

	"git.sr.ht/~sircmpwn/core-go/auth"
	"github.com/dolthub/dolt/go/libraries/doltcore/creds"
	jose "gopkg.in/go-jose/go-jose.v2"
	"gopkg.in/go-jose/go-jose.v2/jwt"
)

// jwtSubjectPrefix is the fixed prefix dolt puts in the JWT "sub" claim,
// followed by the base32 key id: "doltClientCredentials/<kid>". See dolt's
// creds.DoltCreds.RPCCreds.
const jwtSubjectPrefix = "doltClientCredentials/"

// jwtLeeway is the clock-skew tolerance applied to the exp claim. dolt mints
// 30-second tokens; a minute of leeway absorbs modest clock drift between the
// client and this server without meaningfully extending a token's usefulness.
const jwtLeeway = 1 * time.Minute

// KeyStore looks up dolt Ed25519 public keys by their key id and records key
// usage. It is implemented by the db package (concurrently); authn defines it
// here to stay independent of the database. A key id is
// creds.PubKeyToKIDStr(pubkey): base32(SHA-512/224(pubkey)) over dolt's custom
// alphabet.
type KeyStore interface {
	// ByKID returns the 32-byte Ed25519 public key registered under kid and the
	// username of its owner. It returns an error if no such key exists.
	ByKID(ctx context.Context, kid string) (pubkey []byte, username string, err error)
	// TouchLastUsed records that the key identified by kid was just used.
	TouchLastUsed(ctx context.Context, kid string) error
}

// ResolveDoltJWT resolves the caller for a dolt keypair Bearer token: an EdDSA
// JWS minted by `dolt clone/push` from a `dolt creds`/`dolt login` keypair. It:
//
//   - parses the JWS and requires alg == EdDSA (defeats alg-confusion / "none");
//   - reads the kid header and looks the public key up via keys.ByKID;
//   - cross-checks kid == creds.PubKeyToKIDStr(pubkey) — the same derivation
//     dolt uses — so a corrupt (kid, pubkey) pairing cannot be trusted;
//   - verifies the EdDSA signature with the stored public key;
//   - requires aud == expectedAud and a valid exp (with jwtLeeway), and
//     sub == "doltClientCredentials/<kid>"; the iss claim is deliberately
//     IGNORED (dolt hardcodes iss = "dolt-client.dolthub.com");
//   - resolves the owning user via the meta backend and records key usage.
//
// Permanent rejections wrap ErrInvalidToken; a backend failure (user lookup)
// is returned unwrapped so callers treat it as transient. A TouchLastUsed
// failure is non-fatal (bookkeeping only) and is logged, not returned.
func ResolveDoltJWT(ctx context.Context, rawJWT, expectedAud string, keys KeyStore) (*auth.AuthContext, error) {
	tok, err := jwt.ParseSigned(rawJWT)
	if err != nil {
		return nil, fmt.Errorf("%w: parsing JWS: %v", ErrInvalidToken, err)
	}
	if len(tok.Headers) != 1 {
		return nil, fmt.Errorf("%w: expected exactly one signature, got %d", ErrInvalidToken, len(tok.Headers))
	}
	hdr := tok.Headers[0]
	if hdr.Algorithm != string(jose.EdDSA) {
		return nil, fmt.Errorf("%w: unexpected signature algorithm %q (want EdDSA)", ErrInvalidToken, hdr.Algorithm)
	}
	kid := hdr.KeyID
	if kid == "" {
		return nil, fmt.Errorf("%w: missing kid header", ErrInvalidToken)
	}

	pubkey, username, err := keys.ByKID(ctx, kid)
	if err != nil {
		return nil, fmt.Errorf("%w: unknown key id %q: %v", ErrInvalidToken, kid, err)
	}
	if len(pubkey) != ed25519.PublicKeySize {
		return nil, fmt.Errorf("%w: stored public key for %q has wrong size %d", ErrInvalidToken, kid, len(pubkey))
	}
	// Integrity: the key id must be the canonical derivation of this public
	// key, computed exactly as dolt does. Guards against a mismatched DB row.
	if subtle.ConstantTimeCompare([]byte(creds.PubKeyToKIDStr(pubkey)), []byte(kid)) != 1 {
		return nil, fmt.Errorf("%w: key id %q does not match its stored public key", ErrInvalidToken, kid)
	}

	var claims jwt.Claims
	if err := tok.Claims(ed25519.PublicKey(pubkey), &claims); err != nil {
		return nil, fmt.Errorf("%w: signature verification failed: %v", ErrInvalidToken, err)
	}

	// Validate aud + exp/nbf (with leeway). Issuer and Subject are intentionally
	// left unset in Expected: iss is ignored, and sub is checked separately
	// below against the exact "doltClientCredentials/<kid>" form.
	if err := claims.ValidateWithLeeway(jwt.Expected{
		Audience: jwt.Audience{expectedAud},
		Time:     nowFn(),
	}, jwtLeeway); err != nil {
		return nil, fmt.Errorf("%w: claim validation failed: %v", ErrInvalidToken, err)
	}
	if claims.Subject != jwtSubjectPrefix+kid {
		return nil, fmt.Errorf("%w: subject %q is not %s%s", ErrInvalidToken, claims.Subject, jwtSubjectPrefix, kid)
	}

	var ac auth.AuthContext
	if err := meta.LookupUser(ctx, username, &ac); err != nil {
		return nil, fmt.Errorf("looking up user %q: %w", username, err)
	}
	ac.AuthMethod = AuthMethodDoltKey

	if err := keys.TouchLastUsed(ctx, kid); err != nil {
		// Non-fatal: the caller is already authenticated; last_used is display
		// metadata. Log and continue rather than failing the clone/push.
		log.Printf("authn: TouchLastUsed(%q): %v", kid, err)
	}
	return &ac, nil
}

A authn/jwt_test.go => authn/jwt_test.go +228 -0
@@ 0,0 1,228 @@
package authn

import (
	"context"
	"crypto/ed25519"
	"errors"
	"testing"
	"time"

	"git.sr.ht/~sircmpwn/core-go/auth"
	"github.com/dolthub/dolt/go/libraries/doltcore/creds"
)

const testAud = "dolt.srht.bigb.es"

type stubKey struct {
	pubkey   []byte
	username string
}

type stubKeyStore struct {
	keys     map[string]stubKey
	touched  []string
	byKIDErr error
	touchErr error
}

func (s *stubKeyStore) ByKID(ctx context.Context, kid string) ([]byte, string, error) {
	if s.byKIDErr != nil {
		return nil, "", s.byKIDErr
	}
	k, ok := s.keys[kid]
	if !ok {
		return nil, "", errors.New("no such key")
	}
	return k.pubkey, k.username, nil
}

func (s *stubKeyStore) TouchLastUsed(ctx context.Context, kid string) error {
	if s.touchErr != nil {
		return s.touchErr
	}
	s.touched = append(s.touched, kid)
	return nil
}

// doltKeypair generates an Ed25519 keypair and its dolt key id, and returns a
// KeyStore that recognises it plus a MetaBackend that knows its owner.
func doltKeypair(t *testing.T, username string) (ed25519.PrivateKey, string, *stubKeyStore) {
	t.Helper()
	pub, priv, err := ed25519.GenerateKey(nil)
	if err != nil {
		t.Fatal(err)
	}
	kid := creds.PubKeyToKIDStr(pub)
	ks := &stubKeyStore{keys: map[string]stubKey{
		kid: {pubkey: pub, username: username},
	}}
	return priv, kid, ks
}

func validSub(kid string) string { return jwtSubjectPrefix + kid }

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

	raw := mintDoltJWT(t, priv, kid, testAud, creds.ClientIssuer, validSub(kid), time.Now().Add(30*time.Second))
	ac, err := ResolveDoltJWT(testCtx(), raw, testAud, ks)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if ac.Username != "bigbes" || ac.UserID != 1 {
		t.Fatalf("wrong user resolved: %+v", ac)
	}
	if ac.AuthMethod != AuthMethodDoltKey {
		t.Fatalf("AuthMethod = %q, want %q", ac.AuthMethod, AuthMethodDoltKey)
	}
	if len(ks.touched) != 1 || ks.touched[0] != kid {
		t.Fatalf("expected TouchLastUsed(%q), got %v", kid, ks.touched)
	}
}

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

	// Deliberately weird issuer; must still be accepted (iss is ignored).
	raw := mintDoltJWT(t, priv, kid, testAud, "whatever.example.com", validSub(kid), time.Now().Add(30*time.Second))
	if _, err := ResolveDoltJWT(testCtx(), raw, testAud, ks); err != nil {
		t.Fatalf("issuer must be ignored, got %v", err)
	}
}

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

	// Sign with a different private key than the one registered for kid.
	_, wrongPriv, err := ed25519.GenerateKey(nil)
	if err != nil {
		t.Fatal(err)
	}
	raw := mintDoltJWT(t, wrongPriv, kid, testAud, creds.ClientIssuer, validSub(kid), time.Now().Add(30*time.Second))
	_, err = ResolveDoltJWT(testCtx(), raw, testAud, ks)
	if !errors.Is(err, ErrInvalidToken) {
		t.Fatalf("wrong signing key must wrap ErrInvalidToken, got %v", err)
	}
}

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

	// Expired well beyond the leeway window.
	raw := mintDoltJWT(t, priv, kid, testAud, creds.ClientIssuer, validSub(kid), time.Now().Add(-10*time.Minute))
	_, err := ResolveDoltJWT(testCtx(), raw, testAud, ks)
	if !errors.Is(err, ErrInvalidToken) {
		t.Fatalf("expired token must wrap ErrInvalidToken, got %v", err)
	}
}

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

	raw := mintDoltJWT(t, priv, kid, "some.other.host", creds.ClientIssuer, validSub(kid), time.Now().Add(30*time.Second))
	_, err := ResolveDoltJWT(testCtx(), raw, testAud, ks)
	if !errors.Is(err, ErrInvalidToken) {
		t.Fatalf("wrong audience must wrap ErrInvalidToken, got %v", err)
	}
}

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

	raw := mintDoltJWT(t, priv, kid, testAud, creds.ClientIssuer, "doltClientCredentials/wrong", time.Now().Add(30*time.Second))
	_, err := ResolveDoltJWT(testCtx(), raw, testAud, ks)
	if !errors.Is(err, ErrInvalidToken) {
		t.Fatalf("bad subject must wrap ErrInvalidToken, got %v", err)
	}
}

func TestResolveDoltJWT_UnknownKID(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
		"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
	}})
	priv, kid, _ := doltKeypair(t, "bigbes")
	emptyKS := &stubKeyStore{keys: map[string]stubKey{}}

	raw := mintDoltJWT(t, priv, kid, testAud, creds.ClientIssuer, validSub(kid), time.Now().Add(30*time.Second))
	_, err := ResolveDoltJWT(testCtx(), raw, testAud, emptyKS)
	if !errors.Is(err, ErrInvalidToken) {
		t.Fatalf("unknown kid must wrap ErrInvalidToken, got %v", err)
	}
}

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

	// Register the kid against an unrelated public key: integrity check fails.
	otherPub, _, err := ed25519.GenerateKey(nil)
	if err != nil {
		t.Fatal(err)
	}
	badKS := &stubKeyStore{keys: map[string]stubKey{kid: {pubkey: otherPub, username: "bigbes"}}}

	raw := mintDoltJWT(t, priv, kid, testAud, creds.ClientIssuer, validSub(kid), time.Now().Add(30*time.Second))
	_, err = ResolveDoltJWT(testCtx(), raw, testAud, badKS)
	if !errors.Is(err, ErrInvalidToken) {
		t.Fatalf("kid/pubkey mismatch must wrap ErrInvalidToken, got %v", err)
	}
}

func TestResolveDoltJWT_TouchFailureIsNonFatal(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
		"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
	}})
	priv, kid, ks := doltKeypair(t, "bigbes")
	ks.touchErr = errors.New("db write failed")

	raw := mintDoltJWT(t, priv, kid, testAud, creds.ClientIssuer, validSub(kid), time.Now().Add(30*time.Second))
	if _, err := ResolveDoltJWT(testCtx(), raw, testAud, ks); err != nil {
		t.Fatalf("TouchLastUsed failure must not fail auth, got %v", err)
	}
}

func TestResolveDoltJWT_UserLookupFailureIsTransient(t *testing.T) {
	withStubBackend(t, &stubBackend{lookupErr: errBackendDown})
	priv, kid, ks := doltKeypair(t, "bigbes")

	raw := mintDoltJWT(t, priv, kid, testAud, creds.ClientIssuer, validSub(kid), time.Now().Add(30*time.Second))
	_, err := ResolveDoltJWT(testCtx(), raw, testAud, ks)
	if err == nil {
		t.Fatal("expected an error")
	}
	if errors.Is(err, ErrInvalidToken) {
		t.Fatalf("user-lookup failure must be transient, got %v", err)
	}
}

// PubKeyToKIDStr is dolt's own derivation; this asserts our generated keypair's
// kid is self-consistent (the value production and tests both rely on).
func TestKIDDerivationMatchesDolt(t *testing.T) {
	dc, err := creds.GenerateCredentials()
	if err != nil {
		t.Fatal(err)
	}
	if got := creds.PubKeyToKIDStr(dc.PubKey); got != dc.KeyIDBase32Str() {
		t.Fatalf("PubKeyToKIDStr(%x) = %q, want %q", dc.PubKey, got, dc.KeyIDBase32Str())
	}
}

A authn/token.go => authn/token.go +143 -0
@@ 0,0 1,143 @@
package authn

import (
	"context"
	"crypto/sha512"
	"fmt"
	"sync"
	"time"

	"git.sr.ht/~sircmpwn/core-go/auth"

	"go.bigb.es/sourcehut-dolt/core"
)

// RepoScope is the OAuth grant scope a meta.sr.ht personal access token must
// carry to act on dolt.sr.ht repositories: "dolt.sr.ht/repos". Reads require
// ":RO", pushes require ":RW". Personal tokens with no explicit grants are
// universal and pass unconditionally (auth.Grants.HasAll semantics).
const RepoScope = "dolt.sr.ht/repos"

// tokenCacheTTL bounds how long a positively-resolved Basic token is trusted
// without re-checking revocation on meta.sr.ht. A single push issues many RPCs;
// caching keeps each from hammering meta while bounding the revocation-lag
// window to this duration.
const tokenCacheTTL = 60 * time.Second

type cacheEntry struct {
	ac      *auth.AuthContext
	expires time.Time
}

var (
	tokenCacheMu sync.Mutex
	tokenCache   = map[[64]byte]cacheEntry{}
	// nowFn is overridable in tests to exercise cache expiry deterministically.
	nowFn = time.Now
)

func cacheGet(key [64]byte) *auth.AuthContext {
	tokenCacheMu.Lock()
	defer tokenCacheMu.Unlock()
	e, ok := tokenCache[key]
	if !ok {
		return nil
	}
	if !nowFn().Before(e.expires) {
		delete(tokenCache, key)
		return nil
	}
	return e.ac
}

func cachePut(key [64]byte, ac *auth.AuthContext) {
	tokenCacheMu.Lock()
	defer tokenCacheMu.Unlock()
	tokenCache[key] = cacheEntry{ac: ac, expires: nowFn().Add(tokenCacheTTL)}
}

// ResolveBasic resolves the caller for a Basic-auth credential: a meta.sr.ht
// personal access token presented as the password alongside username. It
// implements core-go's OAuth2 validation trio, offline-first:
//
//  1. auth.DecodeBearerToken(password) — offline HMAC + expiry check.
//  2. The token's own username must equal the presented username (case- and
//     "~"-insensitive), so a token cannot be used to impersonate another user.
//  3. meta.LookupUser (mirror the profile) + meta.IsRevoked (revocation check).
//
// A positive result is cached for tokenCacheTTL keyed by sha512(password);
// negative results are never cached. Suspended users resolve successfully — the
// suspension flag rides on the caller and gates writes at the access layer.
//
// Permanent rejections (bad/expired token, username mismatch, revoked) wrap
// ErrInvalidToken; a backend failure (meta unreachable, database error) is
// returned unwrapped so callers treat it as transient. See package core for how
// the resulting grants are enforced (TokenGrantsAllow).
func ResolveBasic(ctx context.Context, username, password string) (*auth.AuthContext, error) {
	hash := sha512.Sum512([]byte(password))

	if ac := cacheGet(hash); ac != nil {
		// Guard against a cached entry being reused under a different presented
		// username (same password could only be the same token, but check
		// anyway — defence in depth costs nothing here).
		if equalUsername(username, ac.Username) {
			return ac, nil
		}
	}

	bt := auth.DecodeBearerToken(password)
	if bt == nil {
		return nil, fmt.Errorf("%w: token failed HMAC/expiry validation", ErrInvalidToken)
	}
	if !equalUsername(bt.Username, username) {
		return nil, fmt.Errorf("%w: token belongs to %q, not presented user %q",
			ErrInvalidToken, bt.Username, username)
	}

	var ac auth.AuthContext
	if err := meta.LookupUser(ctx, bt.Username, &ac); err != nil {
		return nil, fmt.Errorf("looking up user %q: %w", bt.Username, err)
	}

	revoked, err := meta.IsRevoked(ctx, bt.Username, hash, bt.ClientID)
	if err != nil {
		return nil, fmt.Errorf("checking token revocation for %q: %w", bt.Username, err)
	}
	if revoked {
		return nil, fmt.Errorf("%w: token has been revoked", ErrInvalidToken)
	}

	grants, err := auth.DecodeGrants(ctx, bt.Grants)
	if err != nil {
		return nil, fmt.Errorf("%w: decoding token grants: %v", ErrInvalidToken, err)
	}

	ac.AuthMethod = auth.AUTH_OAUTH2
	ac.BearerToken = bt
	ac.TokenHash = hash
	ac.Grants = grants

	cachePut(hash, &ac)
	return &ac, nil
}

// TokenGrantsAllow reports whether the caller's token grants permit access at
// the given mode (core.AccessRO for browse/clone, core.AccessRW for push) on
// dolt.sr.ht repositories. It is the OAuth-grant gate that complements the ACL
// decision in core.Allowed: a token must carry BOTH sufficient grants and a
// sufficient ACL/visibility to act.
//
// Non-token callers (anonymous, cookie, or dolt-key auth) carry no OAuth grants
// and are not scoped by them, so they pass this gate unconditionally; their
// access is decided solely by core.Allowed. Personal tokens with empty grants
// are universal and also pass.
func TokenGrantsAllow(ac *auth.AuthContext, mode core.AccessMode) bool {
	if ac == nil || ac.BearerToken == nil {
		return true
	}
	kind := auth.RO
	if mode == core.AccessRW {
		kind = auth.RW
	}
	return ac.Grants.Has(RepoScope, kind)
}

A authn/token_test.go => authn/token_test.go +235 -0
@@ 0,0 1,235 @@
package authn

import (
	"context"
	"crypto/sha512"
	"errors"
	"testing"
	"time"

	"git.sr.ht/~sircmpwn/core-go/auth"

	"go.bigb.es/sourcehut-dolt/core"
)

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

	ac, err := ResolveBasic(testCtx(), "bigbes", pat)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if ac.Username != "bigbes" || ac.UserID != 1 {
		t.Fatalf("wrong user resolved: %+v", ac)
	}
	if ac.AuthMethod != auth.AUTH_OAUTH2 {
		t.Fatalf("AuthMethod = %q, want %q", ac.AuthMethod, auth.AUTH_OAUTH2)
	}
	if ac.TokenHash != sha512.Sum512([]byte(pat)) {
		t.Fatal("TokenHash must be sha512(password)")
	}
}

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

	// Presented username differs by a leading "~" and case; must still match.
	if _, err := ResolveBasic(testCtx(), "~BigBes", pat); err != nil {
		t.Fatalf("expected match for ~BigBes, got %v", err)
	}
}

func TestResolveBasic_UsernameMismatch(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
		"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
		"mallory": sampleUser(2, "mallory", auth.USER_TYPE_USER),
	}})
	pat := forgePAT("bigbes", "", time.Now().Add(time.Hour))

	_, err := ResolveBasic(testCtx(), "mallory", pat)
	if !errors.Is(err, ErrInvalidToken) {
		t.Fatalf("username mismatch must wrap ErrInvalidToken, got %v", err)
	}
}

func TestResolveBasic_ExpiredToken(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
		"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
	}})
	pat := forgePAT("bigbes", "", time.Now().Add(-time.Minute))

	_, err := ResolveBasic(testCtx(), "bigbes", pat)
	if !errors.Is(err, ErrInvalidToken) {
		t.Fatalf("expired token must wrap ErrInvalidToken, got %v", err)
	}
}

func TestResolveBasic_GarbageToken(t *testing.T) {
	withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{}})
	_, err := ResolveBasic(testCtx(), "bigbes", "this-is-not-a-token")
	if !errors.Is(err, ErrInvalidToken) {
		t.Fatalf("garbage token must wrap ErrInvalidToken, got %v", err)
	}
}

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

	_, err := ResolveBasic(testCtx(), "bigbes", pat)
	if !errors.Is(err, ErrInvalidToken) {
		t.Fatalf("revoked token must wrap ErrInvalidToken, got %v", err)
	}
}

func TestResolveBasic_BackendDownIsTransient(t *testing.T) {
	withStubBackend(t, &stubBackend{lookupErr: errBackendDown})
	pat := forgePAT("bigbes", "", time.Now().Add(time.Hour))

	_, err := ResolveBasic(testCtx(), "bigbes", pat)
	if err == nil {
		t.Fatal("expected an error")
	}
	if errors.Is(err, ErrInvalidToken) {
		t.Fatalf("backend failure must NOT be a permanent rejection, got %v", err)
	}
}

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

	_, err := ResolveBasic(testCtx(), "bigbes", pat)
	if err == nil {
		t.Fatal("expected an error")
	}
	if errors.Is(err, ErrInvalidToken) {
		t.Fatalf("revocation backend failure must be transient, got %v", err)
	}
}

// countingBackend records how many times LookupUser is called, to observe caching.
type countingBackend struct {
	*stubBackend
	lookups int
}

func (c *countingBackend) LookupUser(ctx context.Context, username string, out *auth.AuthContext) error {
	c.lookups++
	return c.stubBackend.LookupUser(ctx, username, out)
}

func TestResolveBasic_PositiveCache(t *testing.T) {
	cb := &countingBackend{stubBackend: &stubBackend{
		users: map[string]auth.AuthContext{"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER)},
	}}
	withStubBackend(t, cb)
	pat := forgePAT("bigbes", "", time.Now().Add(time.Hour))

	for i := 0; i < 3; i++ {
		if _, err := ResolveBasic(testCtx(), "bigbes", pat); err != nil {
			t.Fatalf("call %d: %v", i, err)
		}
	}
	if cb.lookups != 1 {
		t.Fatalf("expected 1 backend lookup (rest cached), got %d", cb.lookups)
	}
}

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

	base := time.Now()
	nowFn = func() time.Time { return base }
	pat := forgePAT("bigbes", "", base.Add(time.Hour))

	if _, err := ResolveBasic(testCtx(), "bigbes", pat); err != nil {
		t.Fatal(err)
	}
	// Advance past the TTL; the entry must be re-resolved.
	nowFn = func() time.Time { return base.Add(tokenCacheTTL + time.Second) }
	if _, err := ResolveBasic(testCtx(), "bigbes", pat); err != nil {
		t.Fatal(err)
	}
	if cb.lookups != 2 {
		t.Fatalf("expected 2 lookups across the TTL boundary, got %d", cb.lookups)
	}
}

func TestResolveBasic_NegativeNotCached(t *testing.T) {
	cb := &countingBackend{stubBackend: &stubBackend{
		users: map[string]auth.AuthContext{"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER)},
	}}
	withStubBackend(t, cb)
	// A token for an unknown user: LookupUser errors each time (uncached).
	pat := forgePAT("ghost", "", time.Now().Add(time.Hour))

	for i := 0; i < 2; i++ {
		if _, err := ResolveBasic(testCtx(), "ghost", pat); err == nil {
			t.Fatalf("call %d: expected error for unknown user", i)
		}
	}
	if cb.lookups != 2 {
		t.Fatalf("negative results must not be cached, got %d lookups", cb.lookups)
	}
}

func TestTokenGrantsAllow(t *testing.T) {
	ctx := testCtx()

	mustGrants := func(s string) auth.Grants {
		g, err := auth.DecodeGrants(ctx, s)
		if err != nil {
			t.Fatalf("DecodeGrants(%q): %v", s, err)
		}
		return g
	}

	patAC := func(grants string) *auth.AuthContext {
		return &auth.AuthContext{
			AuthMethod:  auth.AUTH_OAUTH2,
			BearerToken: &auth.BearerToken{},
			Grants:      mustGrants(grants),
		}
	}

	cases := []struct {
		name    string
		ac      *auth.AuthContext
		mode    core.AccessMode
		want    bool
	}{
		{"nil caller passes", nil, core.AccessRW, true},
		{"cookie (no bearer) passes", &auth.AuthContext{AuthMethod: auth.AUTH_COOKIE}, core.AccessRW, true},
		{"empty grants RO", patAC(""), core.AccessRO, true},
		{"empty grants RW", patAC(""), core.AccessRW, true},
		{"repos:RO allows read", patAC("dolt.sr.ht/repos:RO"), core.AccessRO, true},
		{"repos:RO denies write", patAC("dolt.sr.ht/repos:RO"), core.AccessRW, false},
		{"repos:RW allows read", patAC("dolt.sr.ht/repos:RW"), core.AccessRO, true},
		{"repos:RW allows write", patAC("dolt.sr.ht/repos:RW"), core.AccessRW, true},
		{"unrelated scope denies read", patAC("git.sr.ht/repos:RW"), core.AccessRO, false},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := TokenGrantsAllow(tc.ac, tc.mode); got != tc.want {
				t.Fatalf("TokenGrantsAllow = %v, want %v", got, tc.want)
			}
		})
	}
}