~bigbes/sr-ht-spec

sr-ht-spec/graph/credential_test.go -rw-r--r-- 15.8 KiB
64cae3af — Eugene Blikh graph: accept a meta.sr.ht token, so /query can be federated a day 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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
package graph

import (
	"bytes"
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
	"sourcecraft.dev/bigbes/sr-ht-ecore/metapat"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
)

// The credential plane of /query, end to end through the real handler chain.
//
// grant_test.go exercises the gate as a unit, over a principal somebody handed
// it. These go in at the door instead — an HTTP request carrying a credential —
// because what changed in the conversion is which credentials reach the gate at
// all, and a test written against a Principal cannot see that.

// probeQuery is a cheap read: it needs no fixture beyond the fake reader and it
// is refused before parsing when the caller has no authority, so the status is
// the whole answer.
const probeQuery = `{ spaces { ref } }`

// request builds the POST the harness would send, and hands back the recorder
// as well, for the assertions that are about a header rather than a body.
func request(t *testing.T, q string, credential func(*http.Request)) (*http.Request, *httptest.ResponseRecorder) {
	t.Helper()
	body, err := json.Marshal(map[string]any{"query": q})
	require.NoError(t, err)
	req := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	if credential != nil {
		credential(req)
	}
	return req, httptest.NewRecorder()
}

// personalToken mints a bearer token sealed with the instance's key but stamped
// with an OAuth client id rather than tokens.sr.ht's, carrying grantString in
// meta.sr.ht's own vocabulary — "spec.sr.ht/SPECS:RO", or "" for the ungranted
// token meta treats as universal.
//
// That is exactly what a meta.sr.ht personal access token is on this instance:
// same format, same key, different issuer. The client id is the ONLY thing that
// tells the two planes apart (see sr-ht-ecore/bearer, step 2), which is why
// routing can be one local decode and why this fixture needs nothing else to be
// a convincing PAT.
func personalToken(username, grantString string) string {
	bt := &auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(time.Now().Add(time.Hour)),
		Grants:   grantString,
		ClientID: "00000000-0000-0000-0000-00000000beef",
		Username: username,
	}
	return bt.Encode()
}

// expiredPersonalToken is a PAT whose expiry is already in the past. It is worth
// a fixture of its own because of where it lands: auth.DecodeBearerToken checks
// expiry before it reports an issuer, so metapat.PlaneOf answers PlaneUnknown and
// this never reaches the meta plane at all — see resolveCaller's routing table.
func expiredPersonalToken(username string) string {
	bt := &auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(time.Now().Add(-time.Hour)),
		ClientID: "00000000-0000-0000-0000-00000000beef",
		Username: username,
	}
	return bt.Encode()
}

// patBackend is metapat's port over the same fixed user stubUsers answers the
// working-token plane with, so both planes resolve one owner to one row and a
// test that compares them is comparing planes rather than two fixtures.
//
// It answers "not revoked" always. Revocation is metapat's own contract and is
// tested there against a backend that says otherwise; what this suite is about is
// which plane a credential reaches and what it may then read.
type patBackend struct{}

func (patBackend) LookupUser(_ context.Context, username string, out *auth.AuthContext) error {
	out.UserID = 1
	out.Username = username
	return nil
}

func (patBackend) IsRevoked(context.Context, string, [64]byte, string) (bool, error) {
	return false, nil
}

// testMetaAuth is the production meta.sr.ht plane over ecore's real
// metapat.Validator. Nothing in the signature check, the grant decoding or the
// scope comparison is faked — only the profile mirror behind them, which is the
// one step that would need a meta.sr.ht.
func testMetaAuth(t *testing.T) *authn.MetaAuth {
	t.Helper()
	pats, err := metapat.New(metapat.Options{
		Service: authn.ConfigSection,
		Backend: patBackend{},
	})
	require.NoError(t, err)
	plane, err := authn.NewMetaAuth(pats, "bigbes", authn.ScopeRead)
	require.NoError(t, err)
	return plane
}

// foreignToken mints a working token belonging to somebody who is not the
// instance owner.
func foreignToken(grantString string) string {
	bt := &auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(time.Now().Add(time.Hour)),
		Grants:   grantString,
		ClientID: bearer.TokensClientID,
		Username: "somebody-else",
	}
	return bt.Encode()
}

// A tokens.sr.ht working token carrying spec:read reads. This is the credential
// the whole conversion is for: the same one /mcp and the REST write plane take,
// so a token that works against one surface of this service works against all of
// them.
func TestWorkingTokenWithTheReadGrantReads(t *testing.T) {
	h := newHarness(t, false)

	r := post(t, h, probeQuery, func(req *http.Request) {
		req.Header.Set("Authorization", "Bearer "+agentToken(authn.ActionRead))
	})

	require.Equal(t, http.StatusOK, r.status, "body %s", r.body)
	assert.Empty(t, r.errText())
	assert.Contains(t, string(r.Data), "~bigbes/rfcs")
}

// The universal grant covers spec:read like any other action, so a token minted
// with "*" reads too. It is asserted separately because "*" is not a member of
// the set and a grant check written as a set lookup would refuse it.
func TestUniversalGrantReads(t *testing.T) {
	h := newHarness(t, false)

	r := post(t, h, probeQuery, func(req *http.Request) {
		req.Header.Set("Authorization", "Bearer "+agentToken("*"))
	})

	require.Equal(t, http.StatusOK, r.status, "body %s", r.body)
	assert.Empty(t, r.errText())
}

// A working token that verifies but was not minted for reading is 403 and not
// 401: the credential is good and the holder is known, so retrying with it is
// pointless and what they need is a wider grant. The refusal names the grant,
// because a client that is not told which one it lacks cannot ask for it.
func TestWorkingTokenWithoutTheReadGrantIsRefused(t *testing.T) {
	h := newHarness(t, false)

	r := post(t, h, probeQuery, func(req *http.Request) {
		req.Header.Set("Authorization", "Bearer "+agentToken(authn.ActionPropose))
	})

	require.Equal(t, http.StatusForbidden, r.status, "body %s", r.body)
	assert.Contains(t, r.body, authn.ActionRead)
	assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
}

// The unified-login cookie is not a credential on this endpoint, and the owner's
// own cookie is refused along with everybody else's. That is the half of the
// conversion a status code alone would not prove, so it is asserted twice: on
// the bare handler, and mounted under the very middleware that would resolve the
// cookie into the owner principal. The second case is the one that matters —
// resolveCaller overwrites the principal rather than inheriting it, so no
// arrangement of middleware above the mount point can promote a browser session
// into read authority here.
func TestCookieIsNotACredentialHere(t *testing.T) {
	t.Run("bare handler", func(t *testing.T) {
		h := newHarness(t, false)
		r := post(t, h, probeQuery, func(req *http.Request) { login(req, "bigbes") })

		assert.Equal(t, http.StatusUnauthorized, r.status, "body %s", r.body)
		assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
	})

	t.Run("under a router that does resolve the cookie", func(t *testing.T) {
		resolver := testResolver(t)
		srv, err := New(Options{
			Reader: newFakeReader(), Searcher: &fakeSearcher{},
			Resolver: resolver, Meta: testMetaAuth(t),
		})
		require.NoError(t, err)

		// The cookie plane, installed above the endpoint. It resolves the
		// owner's cookie to authn.KindOwner, which CanRead admits.
		h := harness{handler: resolver.Middleware()(srv)}
		r := post(t, h, probeQuery, func(req *http.Request) { login(req, "bigbes") })

		assert.Equal(t, http.StatusUnauthorized, r.status, "body %s", r.body)
		assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
	})
}

// The other plane, and the reason this endpoint has two. A federated query
// arrives from api.sr.ht carrying the client's own meta.sr.ht token, because the
// gateway forwards one Authorization header to every service it touches — so a
// personal access token has to work here, scoped in meta's vocabulary rather than
// in tokens.sr.ht's.
func TestTheReadScopeIsRequiredOfAPersonalAccessToken(t *testing.T) {
	cases := []struct {
		name   string
		grants string
		status int
	}{
		{"the scope itself", authn.ScopeRead, http.StatusOK},
		{"an explicit :RO", authn.ScopeRead + ":RO", http.StatusOK},
		{"a write scope covers a read", authn.ScopeRead + ":RW", http.StatusOK},
		{
			// meta mints a token with no grants selected and core-go reads that
			// as every permission (auth.Grants.HasAll). Refusing it here would
			// refuse the commonest credential on the instance.
			"no grants at all is universal, as meta defines it", "", http.StatusOK,
		},
		{"another service's scope", "bench.sr.ht/RESULTS:RO", http.StatusForbidden},
		{"meta's own profile scope is not this one", "meta.sr.ht/PROFILE:RO", http.StatusForbidden},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			h := newHarness(t, false)

			r := post(t, h, probeQuery, func(req *http.Request) {
				req.Header.Set("Authorization", "Bearer "+personalToken("bigbes", tc.grants))
			})

			require.Equal(t, tc.status, r.status, "body %s", r.body)
			if tc.status != http.StatusForbidden {
				return
			}
			assert.Contains(t, r.body, authn.ScopeRead,
				"a 403 must name meta's scope, not tokens.sr.ht's grant: a PAT holder "+
					"sent looking for spec:read will not find a checkbox for it")
			assert.NotContains(t, r.body, authn.ActionRead)
			assert.Empty(t, r.header.Get("WWW-Authenticate"),
				"a challenge would send the holder re-minting a token that is fine")
			assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
		})
	}
}

// A personal access token reads as the owner's agent, and the emphasis is on
// agent: it is a bearer string any process holding it can present, forwarded
// through a gateway by whatever client asked, so reading it as authn.KindOwner
// would hand the approved branch and the webhook subscriptions to the widest
// credential on the instance. The scope says the token may read through spec at
// all; who it is stays the ACL's input.
func TestAPersonalAccessTokenReadsAsAnAgentAndNotTheOwner(t *testing.T) {
	h := newHarness(t, false)

	r := post(t, h, probeQuery, func(req *http.Request) {
		req.Header.Set("Authorization", "Bearer "+personalToken("bigbes", authn.ScopeRead))
	})
	require.Equal(t, http.StatusOK, r.status, "body %s", r.body)
	assert.Contains(t, string(r.Data), "~bigbes/rfcs")

	// The principal itself, since a 200 on a read cannot tell an agent from the
	// owner: both may read, and only one may approve.
	plane := testMetaAuth(t)
	p, err := plane.VerifyToken(t.Context(), personalToken("bigbes", authn.ScopeRead))
	require.NoError(t, err)
	assert.True(t, p.IsAgent())
	assert.False(t, p.IsOwner(), "a PAT must not reach the webhook mutations")
	assert.Equal(t, authn.PlaneMeta, p.Plane)
	assert.NoError(t, p.Authorize(authn.ActionRead),
		"a PAT can never carry spec:read; asking for it would refuse every one of them")
}

// A personal access token belonging to another meta.sr.ht account is 403 and is
// not admitted as a second identity — the same rule, and the same status, a
// foreign working token gets.
//
// This is the check that matters most on this plane. A PAT is the credential
// every account on the instance can mint for itself, so without it the widest
// credential in existence would be the one that skipped the narrowest identity
// rule, and any user of this instance could read the whole corpus.
func TestPersonalAccessTokenOfAnotherOwnerIsRefused(t *testing.T) {
	h := newHarness(t, false)

	r := post(t, h, probeQuery, func(req *http.Request) {
		req.Header.Set("Authorization", "Bearer "+personalToken("somebody-else", authn.ScopeRead))
	})

	assert.Equal(t, http.StatusForbidden, r.status, "body %s", r.body)
	assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
}

// The two vocabularies do not overlap, and this is the test that says so. Each
// plane is reached by the client id its token was sealed with, and neither can be
// talked into honouring the other's permission — but they refuse it differently,
// and the difference is worth pinning because it is not arbitrary.
func TestOnePlanesPermissionIsNotTheOthers(t *testing.T) {
	t.Run("a PAT spelling tokens.sr.ht's grant", func(t *testing.T) {
		// Well-formed in meta's grammar — a scope this service does not publish —
		// so it authenticates and is refused on permission: 403.
		h := newHarness(t, false)
		r := post(t, h, probeQuery, func(req *http.Request) {
			req.Header.Set("Authorization",
				"Bearer "+personalToken("bigbes", authn.ConfigSection+"/"+authn.ActionRead))
		})

		assert.Equal(t, http.StatusForbidden, r.status, "body %s", r.body)
	})

	t.Run("a working token spelling meta's scope", func(t *testing.T) {
		// Not even parseable: ecore's grants grammar is "<service>:<action>", and
		// "spec.sr.ht/SPECS" is not a grant string in it. The credential is
		// malformed rather than insufficient, so this is 401 with the challenge —
		// a stricter answer than the one above, arrived at one step earlier.
		h := newHarness(t, false)
		r := post(t, h, probeQuery, func(req *http.Request) {
			req.Header.Set("Authorization", "Bearer "+agentToken(authn.ScopeRead))
		})

		assert.Equal(t, http.StatusUnauthorized, r.status, "body %s", r.body)
		assert.NotEmpty(t, r.header.Get("WWW-Authenticate"))
	})
}

// A working token belonging to another meta.sr.ht account is 403, not 401 and
// not admitted as a second identity: the token verifies and the holder is who
// they say they are, there is simply nothing on this single-owner instance to
// grant them. This is the ownerOnly rule the conversion had to keep, moved from
// a comparison against auth.AuthContext.Username to authn's own.
func TestWorkingTokenOfAnotherOwnerIsRefused(t *testing.T) {
	h := newHarness(t, false)

	r := post(t, h, probeQuery, func(req *http.Request) {
		req.Header.Set("Authorization", "Bearer "+foreignToken(authn.ActionRead))
	})

	assert.Equal(t, http.StatusForbidden, r.status, "body %s", r.body)
	assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
}

// Every 401 carries the challenge, naming the scheme and this service's config
// section as the realm. RFC 9110 requires it, and the caller here is always a
// machine holding a bearer token: without it nothing tells the client which
// credential was refused.
func TestEveryUnauthorizedCarriesTheBearerChallenge(t *testing.T) {
	h := newHarness(t, false)

	cases := map[string]func(*http.Request){
		"no credential":  nil,
		"owner's cookie": func(req *http.Request) { login(req, "bigbes") },
		"a forged token": func(req *http.Request) {
			req.Header.Set("Authorization", "Bearer not-a-real-token")
		},
		// Expired, and therefore unreadable rather than refused: DecodeBearerToken
		// checks expiry before it reports an issuer, so metapat.PlaneOf cannot say
		// which plane sealed this and resolveCaller sends it to the working one.
		// Both planes owe it the same 401, which is exactly why that routing
		// choice is safe — and this asserts the challenge survives it.
		"an expired personal access token": func(req *http.Request) {
			req.Header.Set("Authorization", "Bearer "+expiredPersonalToken("bigbes"))
		},
	}
	for name, credential := range cases {
		t.Run(name, func(t *testing.T) {
			req, rec := request(t, probeQuery, credential)
			h.handler.ServeHTTP(rec, req)

			require.Equal(t, http.StatusUnauthorized, rec.Code, "body %s", rec.Body.String())
			assert.Equal(t, authn.Challenge(), rec.Header().Get("WWW-Authenticate"))
		})
	}
}