diff --git a/authn/authn_test.go b/authn/authn_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ce6f2b450589a9f9e3576471af2fddabdd75aebe --- /dev/null +++ b/authn/authn_test.go @@ -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 +} diff --git a/authn/backend.go b/authn/backend.go new file mode 100644 index 0000000000000000000000000000000000000000..656a81a75cf0c942dd4b0db2374616bfc4fab135 --- /dev/null +++ b/authn/backend.go @@ -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, "~")) +} diff --git a/authn/cookie.go b/authn/cookie.go new file mode 100644 index 0000000000000000000000000000000000000000..f1321c80f63c556789c2a5a9a77898b1a972d922 --- /dev/null +++ b/authn/cookie.go @@ -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 +} diff --git a/authn/cookie_test.go b/authn/cookie_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0d5b6622c2fdb15870682796a1326c84ea309746 --- /dev/null +++ b/authn/cookie_test.go @@ -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) + } +} diff --git a/authn/ctx.go b/authn/ctx.go new file mode 100644 index 0000000000000000000000000000000000000000..a45b060ae3966ac52e6925189a44715a7a7638ae --- /dev/null +++ b/authn/ctx.go @@ -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, + } +} diff --git a/authn/ctx_test.go b/authn/ctx_test.go new file mode 100644 index 0000000000000000000000000000000000000000..29448bc28e685f076196eb8e9d84b148a1398fa5 --- /dev/null +++ b/authn/ctx_test.go @@ -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) + } + }) + } +} diff --git a/authn/grpc.go b/authn/grpc.go new file mode 100644 index 0000000000000000000000000000000000000000..ba999199202a154c8ddd709e850c9fd024e314cf --- /dev/null +++ b/authn/grpc.go @@ -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 " → ResolveBasic (meta personal access token); +// - "Bearer " → 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) + } +} diff --git a/authn/grpc_test.go b/authn/grpc_test.go new file mode 100644 index 0000000000000000000000000000000000000000..691ec6140fcfe0f755f5f1432e693907a65d3c48 --- /dev/null +++ b/authn/grpc_test.go @@ -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) + } + }) + } +} diff --git a/authn/jwt.go b/authn/jwt.go new file mode 100644 index 0000000000000000000000000000000000000000..a6a1a3270858c94615dd642e9a0d14ed17f26b6b --- /dev/null +++ b/authn/jwt.go @@ -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/". 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/"; 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/" 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 +} diff --git a/authn/jwt_test.go b/authn/jwt_test.go new file mode 100644 index 0000000000000000000000000000000000000000..073e5e3d24e2f8460d7d43e31363cd0853e8af17 --- /dev/null +++ b/authn/jwt_test.go @@ -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()) + } +} diff --git a/authn/token.go b/authn/token.go new file mode 100644 index 0000000000000000000000000000000000000000..daa08ec99494a43f50c757e8aa0861c900ef2ab2 --- /dev/null +++ b/authn/token.go @@ -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) +} diff --git a/authn/token_test.go b/authn/token_test.go new file mode 100644 index 0000000000000000000000000000000000000000..624ee0ddf20e140f79eb580ee2bd20052ed59993 --- /dev/null +++ b/authn/token_test.go @@ -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) + } + }) + } +}