~bigbes/sr-ht-spec

ref: 5bb0bb134d608263da3197df9fb4f1d8a3fe42db sr-ht-spec/graph/credential_test.go -rw-r--r-- 8.7 KiB
5bb0bb13 — Eugene Blikh graph: serve /query on the anonymous router with a bearer credential 2 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
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
package graph

import (
	"bytes"
	"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-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()
}

// metaPAT mints a bearer token sealed with the instance's key but stamped with
// an OAuth client id rather than tokens.sr.ht's. That is exactly what a
// meta.sr.ht personal access token is on this instance — same format, same key,
// different issuer — and the client id is the only thing that tells the two
// apart (see sr-ht-ecore/bearer, step 2).
func metaPAT() string {
	bt := &auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(time.Now().Add(time.Hour)),
		Grants:   "",
		ClientID: "00000000-0000-0000-0000-00000000beef",
		Username: "bigbes",
	}
	return bt.Encode()
}

// 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})
		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")
	})
}

// A meta.sr.ht personal access token is refused, and this is a deliberate
// difference from dolt.sr.ht rather than an oversight.
//
// spec.sr.ht authenticates through one issuer (authn's package comment) and
// publishes no OAuth scope for meta to grant against — cmd/specsrht serves
// api-meta.json with an empty scope list — so there is nothing a PAT could be
// scoped *for* here. bearer classifies it ErrNotOurs and authn calls that
// permanent, hence 401 with the challenge rather than a 503.
//
// It is the credential this endpoint used to take, when core-go's
// auth.Middleware stood in front of it, and the test exists to pin the change
// rather than to celebrate it: reopening that plane means giving spec.sr.ht a
// meta scope first.
func TestMetaPersonalAccessTokenIsRefused(t *testing.T) {
	h := newHarness(t, false)

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

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

// 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")
		},
		"a meta PAT": func(req *http.Request) {
			req.Header.Set("Authorization", "Bearer "+metaPAT())
		},
	}
	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"))
		})
	}
}