~bigbes/sr-ht-dolt

ref: 93a10f4843f643c67f30bd2605f4e6134cab324b sr-ht-dolt/authn/authn_test.go -rw-r--r-- 5.0 KiB
93a10f48 — Eugene Blikh ci: pin Go caches inside the APKBUILD, not the env 13 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
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 = &notice
	}
	return ac
}

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

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

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