package authn import ( "context" "crypto/ed25519" "encoding/base64" "encoding/json" "os" "testing" "time" "sourcecraft.dev/bigbes/sr-ht-core/auth" "sourcecraft.dev/bigbes/sr-ht-core/config" "sourcecraft.dev/bigbes/sr-ht-core/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 }