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 = ¬ice
+ }
+ 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)
+ }
+ })
+ }
+}