~bigbes/sr-ht-dolt

ref: dba72f5908d704bbe8b3e8efe35432917192292b sr-ht-dolt/authn/bearer.go -rw-r--r-- 14.3 KiB
dba72f59 — Eugene Blikh mcpsrv: list the memories a tracker holds 5 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
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
package authn

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"

	"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
	"sourcecraft.dev/bigbes/sr-ht-ecore/grants"

	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)

// AuthMethodInstanceToken labels an AuthContext resolved from a tokens.sr.ht
// working token. Like AuthMethodDoltKey it is a private label: core-go has no
// such method, and we never call auth.AuthContext.Access (which would panic on
// an unknown one) — access is decided by core.Allowed — so the label costs
// nothing and keeps this plane distinguishable from a meta.sr.ht PAT in a log
// line or a debugger.
const AuthMethodInstanceToken = "INSTANCE_TOKEN"

// bearerScheme is the RFC 7235 auth-scheme this plane answers to. The
// comparison against it is case-insensitive, as that RFC requires.
const bearerScheme = "Bearer"

// ErrMissingGrant is the sentinel wrapped by every "the credential is good, it
// does not cover this" refusal: a working token without core.GrantRead, or a
// meta PAT whose OAuth grants do not reach dolt.sr.ht repositories. It is a 403
// — retrying with the same token is pointless, and the holder needs to be told
// to ask for a wider grant rather than to authenticate again.
//
// Where it is returned by ResolveBearer it is joined with ErrInvalidToken, so
// that the two-class contract of backend.go stays total: a caller that only
// knows "wraps ErrInvalidToken ⇒ permanent, otherwise ⇒ transient" still
// answers 401 and not 503, while a caller that can say 403 asks for this
// sentinel first. Authorize is not part of that contract — it is asked after
// resolution succeeded, by a surface that already knows this package — so it
// wraps this sentinel alone.
var ErrMissingGrant = errors.New("authn: credential does not carry the required grant")

// ParseBearer returns the token from an "Authorization: Bearer <token>" header,
// or "" when the header is absent or names another scheme.
//
// Another scheme is silently no token rather than an error. Basic is dolt's own
// remote flow (ResolveBasic) and is not this plane's to reject; a request
// holding one, or holding nothing at all, must fall through to whatever the
// caller does with an anonymous request, not be refused in the name of a
// credential it never claimed to present.
func ParseBearer(r *http.Request) string {
	h := r.Header.Get("Authorization")
	if h == "" {
		return ""
	}
	scheme, rest, ok := strings.Cut(h, " ")
	if !ok || !strings.EqualFold(scheme, bearerScheme) {
		return ""
	}
	return strings.TrimSpace(rest)
}

// InstanceValidator is the slice of sr-ht-ecore's bearer.Validator this plane
// uses: the four steps of the tokens SPEC ch. 6 minus the grant check, which is
// asked where the action is known and therefore not here (see bearer.Inspect,
// and BearerCaller.Authorize below).
//
// The interface is declared in the consumer, as MetaBackend is and for the same
// reason: it states exactly how much of the shared validator this service
// depends on — one method — and it is what lets both arms of ResolveBearer be
// tested without a tokens.sr.ht, without a network and without Postgres.
type InstanceValidator interface {
	// Inspect verifies signature, version and expiry locally, checks that the
	// token is one tokens.sr.ht sealed, and asks the daemon whether a registered
	// token is still live. It answers with the owner, the parsed grants and the
	// row id, or with one of the bearer package's sentinels.
	Inspect(ctx context.Context, presented string) (*bearer.Token, error)
}

// Compile-time proof that the shared validator satisfies the port; it is what
// lets this package depend on the interface rather than on *bearer.Validator,
// and it fails the build the moment either side drifts.
var _ InstanceValidator = (*bearer.Validator)(nil)

// BearerCaller is what a presented bearer credential resolves to: the identity,
// and — for a working token — what that token is allowed to ask for.
//
// It is deliberately small and it is not a second caller model. AuthContext is
// the same *auth.AuthContext every other plane in this package produces, so
// AsCoreCaller keeps working and the access matrix in package core is unchanged;
// this type exists only so that a surface can ask the one further question a
// tokens.sr.ht credential brings with it ("may this token do X?"), which an
// AuthContext has no vocabulary for.
type BearerCaller struct {
	// AuthContext is the resolved identity, never nil on a successful resolve.
	AuthContext *auth.AuthContext

	// InstanceToken reports which of the two bearer shapes this was: a
	// tokens.sr.ht working token (true) or a meta.sr.ht personal access token
	// (false). Only the ClientID distinguishes them — both are sealed with the
	// same instance key — and the difference decides which grant vocabulary
	// applies below.
	InstanceToken bool

	// Grants is the tokens.sr.ht grant set of a working token, parsed by
	// sr-ht-ecore/grants and by nothing else. It is the zero value — which
	// admits nothing — for a meta PAT, whose grants are in core-go's entirely
	// different OAuth vocabulary and live on AuthContext.Grants instead. Ask
	// Authorize rather than reading this field, so the distinction stays in one
	// place.
	Grants grants.Grants
}

// Authorize reports whether this caller may perform the named action, e.g.
// core.GrantRead.
//
// A meta PAT passes unconditionally, and that is not a hole. It carries no
// tokens.sr.ht grants at all — the vocabularies do not overlap — and its
// scoping was already applied at resolve time, by the same TokenGrantsAllow
// gate the clone path applies (docs/DESIGN.mcp.md §4.2: a meta PAT and an
// anonymous caller pass this gate; their access is decided by core.Allowed).
// Refusing it here would instead refuse every PAT on the surface, since no PAT
// can ever be minted with a grant string tokens.sr.ht's parser would even read.
//
// A refusal wraps ErrMissingGrant: the credential is good and the caller is who
// they say they are, and what is missing is a permission.
func (c *BearerCaller) Authorize(grant string) error {
	if !c.InstanceToken {
		return nil
	}
	if !c.Grants.Has(grant) {
		return fmt.Errorf("%w: %q is not in %q", ErrMissingGrant, grant, c.Grants.String())
	}
	return nil
}

// ResolveBearer resolves the caller for a bearer credential — the only machine
// credential the /mcp surface accepts (docs/DESIGN.mcp.md §4.1).
//
// The instance issues two bearer shapes, both auth.BearerToken values sealed
// with the same instance key, and only the ClientID tells them apart:
//
//   - bearer.TokensClientID ⇒ a tokens.sr.ht working token. Verified through
//     sr-ht-ecore's validator (signature, version, expiry, ours-ness and, for a
//     registered token, the liveness check against the daemon), its owner
//     mirrored through the same MetaBackend the other planes use, and its grants
//     carried out on the result for BearerCaller.Authorize.
//   - anything else ⇒ a meta.sr.ht personal access token, resolved by
//     ResolveBasic — the decode/lookup/revocation path this package already has.
//     The one difference from the clone flow is that there is no presented
//     username to compare against, so the token's own username *is* the
//     identity. It is then gated by TokenGrantsAllow at core.AccessRO, the check
//     the clone path applies for a read.
//
// The ClientID is read here, by decoding the token once locally, rather than by
// handing everything to Inspect and routing on bearer.ErrNotOurs. Both arms have
// to work when there is no validator at all (see below), so the routing cannot
// live inside the validator; and having it in one place beats having it twice.
// The cost is one extra local HMAC on the working-token arm, which is the
// cheapest step of the four.
//
// v may be nil, and that is a configuration rather than a degradation: an
// instance whose config.ini has no [tokens.sr.ht] section has no such daemon.
// Meta PATs and anonymity keep working; a working token is then refused with
// ErrInvalidToken, because a machine credential this instance cannot verify is
// refused and not guessed at. (A *typed* nil — (*bearer.Validator)(nil) in an
// InstanceValidator — is not that contract and will panic; pass a plain nil.)
//
// Failure is a refusal and never a downgrade to anonymous. An empty presented
// string is refused too: anonymity is the caller's decision, taken before this
// function is reached (ParseBearer returning "" is what it is taken on), and an
// empty credential arriving here is a caller that lost track of its own header.
//
// The error classes are backend.go's, unchanged: a permanent rejection wraps
// ErrInvalidToken (401, additionally ErrMissingGrant for a 403), anything else
// is a transient backend failure returned unwrapped (503).
func ResolveBearer(ctx context.Context, v InstanceValidator, presented string) (*BearerCaller, error) {
	if presented == "" {
		return nil, fmt.Errorf("%w: no bearer token presented", ErrInvalidToken)
	}

	// Step 1 of the tokens SPEC for both arms at once: signature, version and
	// expiry, all local. A forged or expired credential costs one HMAC and never
	// becomes a request to meta.sr.ht or to tokens.sr.ht.
	bt := auth.DecodeBearerToken(presented)
	if bt == nil {
		return nil, fmt.Errorf("%w: token failed HMAC/expiry validation", ErrInvalidToken)
	}

	if bt.ClientID == bearer.TokensClientID {
		return resolveWorkingToken(ctx, v, presented)
	}
	return resolveMetaPAT(ctx, bt.Username, presented)
}

// resolveWorkingToken is the tokens.sr.ht arm.
func resolveWorkingToken(ctx context.Context, v InstanceValidator, presented string) (*BearerCaller, error) {
	if v == nil {
		return nil, fmt.Errorf(
			"%w: this instance configures no [tokens.sr.ht] origin, so a working token cannot be verified",
			ErrInvalidToken)
	}

	tok, err := v.Inspect(ctx, presented)
	if err != nil {
		return nil, classifyInspect(err)
	}

	// The token names a meta.sr.ht account and nothing else; turning that into a
	// local row is the service's job (bearer's package doc says so, and the
	// tokens SPEC ch. 6 prescribes it for every service on the instance). It is
	// the same call the Basic path makes, through the same seam.
	var ac auth.AuthContext
	if err := meta.LookupUser(ctx, tok.Username, &ac); err != nil {
		// Transient: meta or the database could not answer. The credential is
		// good, and telling an agent to re-mint over a lookup outage is the wrong
		// instruction twice — it does not help, and it destroys a working token.
		return nil, fmt.Errorf("looking up user %q: %w", tok.Username, err)
	}
	if ac.UserID == 0 {
		// LookupUser answered without filling in an id. Nothing downstream can
		// use that: every ownership and ACL row keys on the user id, and a zero
		// would match the first repository whose owner id is unset. Permanent
		// rather than transient — retrying will not conjure the account back.
		return nil, fmt.Errorf("%w: working token names %q, for whom no meta id was mirrored",
			ErrInvalidToken, tok.Username)
	}

	ac.AuthMethod = AuthMethodInstanceToken
	// BearerToken and Grants are deliberately left unset. They are core-go's
	// OAuth fields, and filling them would subject this caller to
	// TokenGrantsAllow — a gate demanding "dolt.sr.ht/repos:RO", which a
	// tokens.sr.ht grant string can never spell. A working token is scoped by
	// its own vocabulary, on BearerCaller.Grants, and by core.Allowed.

	return &BearerCaller{
		AuthContext:   &ac,
		InstanceToken: true,
		Grants:        tok.Grants,
	}, nil
}

// resolveMetaPAT is the meta.sr.ht arm: ResolveBasic with the token's own
// username standing in for the presented one, plus the read gate.
func resolveMetaPAT(ctx context.Context, username, presented string) (*BearerCaller, error) {
	// Passing the token's own username makes ResolveBasic's impersonation check
	// a tautology, which is correct here and only here: that check exists to
	// stop a token being used *as* another user's password, and there is no
	// second party's name in a bearer header to be checked against. Everything
	// else it does — the positive cache, the profile mirror, the revocation
	// check, the grant decode — is exactly what this arm needs, and is the
	// reason this is a call and not a copy.
	ac, err := ResolveBasic(ctx, username, presented)
	if err != nil {
		return nil, err
	}

	// The whole surface is a read, so the gate can be applied once here rather
	// than per action. It is the same check the clone path applies.
	if !TokenGrantsAllow(ac, core.AccessRO) {
		return nil, fmt.Errorf("%w: %w: token grants do not permit %s on %s repositories",
			ErrInvalidToken, ErrMissingGrant, core.AccessRO, RepoScope)
	}

	return &BearerCaller{AuthContext: ac, InstanceToken: false}, nil
}

// classifyInspect maps sr-ht-ecore's sentinels onto this package's two error
// classes. It is the one place this service decides what each refusal of the
// shared validator means here.
//
// The 401/503 split is the one that is easy to get wrong and expensive to get
// wrong: an unreachable tokens.sr.ht must not read as a bad credential. "I could
// not check" is not "your token is revoked", and answering 401 there would turn
// a restart of a daemon deliberately kept off the hot path into every agent on
// the instance being told to re-mint its credentials.
//
// bearer.ErrNotOurs is classified for totality and is not reachable: ResolveBearer
// routes on the ClientID before Inspect is called, so a foreign token has already
// gone to the meta arm.
//
// An unrecognised error is transient, which is the fail-closed direction here: a
// sentinel this table has never seen must read as "I could not decide" — a 503
// the caller retries — never as a verdict about the credential.
func classifyInspect(err error) error {
	switch {
	case errors.Is(err, bearer.ErrInvalid),
		errors.Is(err, bearer.ErrNotOurs),
		errors.Is(err, bearer.ErrRevoked):
		return fmt.Errorf("%w: %w", ErrInvalidToken, err)
	case errors.Is(err, bearer.ErrForbidden):
		// Not reachable through Inspect, which is not told an action; classified
		// so the table is total. Joined with ErrInvalidToken for the reason
		// ErrMissingGrant's own comment gives.
		return fmt.Errorf("%w: %w: %w", ErrInvalidToken, ErrMissingGrant, err)
	case errors.Is(err, bearer.ErrUnavailable):
		return fmt.Errorf("asking tokens.sr.ht whether a working token is live: %w", err)
	default:
		return fmt.Errorf("validating a tokens.sr.ht working token: %w", err)
	}
}