| 1 |
|
package authn |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"errors" |
| 6 |
|
"fmt" |
| 7 |
|
"net/http" |
| 8 |
|
"strings" |
| 9 |
|
|
| 10 |
|
"sourcecraft.dev/bigbes/sr-ht-core/auth" |
| 11 |
|
|
| 12 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/bearer" |
| 13 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/grants" |
| 14 |
|
|
| 15 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/core" |
| 16 |
|
) |
| 17 |
|
|
| 18 |
|
// AuthMethodInstanceToken labels an AuthContext resolved from a tokens.sr.ht |
| 19 |
|
// working token. Like AuthMethodDoltKey it is a private label: core-go has no |
| 20 |
|
// such method, and we never call auth.AuthContext.Access (which would panic on |
| 21 |
|
// an unknown one) — access is decided by core.Allowed — so the label costs |
| 22 |
|
// nothing and keeps this plane distinguishable from a meta.sr.ht PAT in a log |
| 23 |
|
// line or a debugger. |
| 24 |
|
const AuthMethodInstanceToken = "INSTANCE_TOKEN" |
| 25 |
|
|
| 26 |
|
// bearerScheme is the RFC 7235 auth-scheme this plane answers to. The |
| 27 |
|
// comparison against it is case-insensitive, as that RFC requires. |
| 28 |
|
const bearerScheme = "Bearer" |
| 29 |
|
|
| 30 |
|
// ErrMissingGrant is the sentinel wrapped by every "the credential is good, it |
| 31 |
|
// does not cover this" refusal: a working token without core.GrantRead, or a |
| 32 |
|
// meta PAT whose OAuth grants do not reach dolt.sr.ht repositories. It is a 403 |
| 33 |
|
// — retrying with the same token is pointless, and the holder needs to be told |
| 34 |
|
// to ask for a wider grant rather than to authenticate again. |
| 35 |
|
// |
| 36 |
|
// Where it is returned by ResolveBearer it is joined with ErrInvalidToken, so |
| 37 |
|
// that the two-class contract of backend.go stays total: a caller that only |
| 38 |
|
// knows "wraps ErrInvalidToken ⇒ permanent, otherwise ⇒ transient" still |
| 39 |
|
// answers 401 and not 503, while a caller that can say 403 asks for this |
| 40 |
|
// sentinel first. Authorize is not part of that contract — it is asked after |
| 41 |
|
// resolution succeeded, by a surface that already knows this package — so it |
| 42 |
|
// wraps this sentinel alone. |
| 43 |
|
var ErrMissingGrant = errors.New("authn: credential does not carry the required grant") |
| 44 |
|
|
| 45 |
|
// ParseBearer returns the token from an "Authorization: Bearer <token>" header, |
| 46 |
|
// or "" when the header is absent or names another scheme. |
| 47 |
|
// |
| 48 |
|
// Another scheme is silently no token rather than an error. Basic is dolt's own |
| 49 |
|
// remote flow (ResolveBasic) and is not this plane's to reject; a request |
| 50 |
|
// holding one, or holding nothing at all, must fall through to whatever the |
| 51 |
|
// caller does with an anonymous request, not be refused in the name of a |
| 52 |
|
// credential it never claimed to present. |
| 53 |
10 |
func ParseBearer(r *http.Request) string { |
| 54 |
10 |
h := r.Header.Get("Authorization") |
| 55 |
10 |
if h == "" { |
| 56 |
1 |
return "" |
| 57 |
1 |
} |
| 58 |
9 |
scheme, rest, ok := strings.Cut(h, " ") |
| 59 |
9 |
if !ok || !strings.EqualFold(scheme, bearerScheme) { |
| 60 |
4 |
return "" |
| 61 |
4 |
} |
| 62 |
5 |
return strings.TrimSpace(rest) |
| 63 |
|
} |
| 64 |
|
|
| 65 |
|
// InstanceValidator is the slice of sr-ht-ecore's bearer.Validator this plane |
| 66 |
|
// uses: the four steps of the tokens SPEC ch. 6 minus the grant check, which is |
| 67 |
|
// asked where the action is known and therefore not here (see bearer.Inspect, |
| 68 |
|
// and BearerCaller.Authorize below). |
| 69 |
|
// |
| 70 |
|
// The interface is declared in the consumer, as MetaBackend is and for the same |
| 71 |
|
// reason: it states exactly how much of the shared validator this service |
| 72 |
|
// depends on — one method — and it is what lets both arms of ResolveBearer be |
| 73 |
|
// tested without a tokens.sr.ht, without a network and without Postgres. |
| 74 |
|
type InstanceValidator interface { |
| 75 |
|
// Inspect verifies signature, version and expiry locally, checks that the |
| 76 |
|
// token is one tokens.sr.ht sealed, and asks the daemon whether a registered |
| 77 |
|
// token is still live. It answers with the owner, the parsed grants and the |
| 78 |
|
// row id, or with one of the bearer package's sentinels. |
| 79 |
|
Inspect(ctx context.Context, presented string) (*bearer.Token, error) |
| 80 |
|
} |
| 81 |
|
|
| 82 |
|
// Compile-time proof that the shared validator satisfies the port; it is what |
| 83 |
|
// lets this package depend on the interface rather than on *bearer.Validator, |
| 84 |
|
// and it fails the build the moment either side drifts. |
| 85 |
|
var _ InstanceValidator = (*bearer.Validator)(nil) |
| 86 |
|
|
| 87 |
|
// BearerCaller is what a presented bearer credential resolves to: the identity, |
| 88 |
|
// and — for a working token — what that token is allowed to ask for. |
| 89 |
|
// |
| 90 |
|
// It is deliberately small and it is not a second caller model. AuthContext is |
| 91 |
|
// the same *auth.AuthContext every other plane in this package produces, so |
| 92 |
|
// AsCoreCaller keeps working and the access matrix in package core is unchanged; |
| 93 |
|
// this type exists only so that a surface can ask the one further question a |
| 94 |
|
// tokens.sr.ht credential brings with it ("may this token do X?"), which an |
| 95 |
|
// AuthContext has no vocabulary for. |
| 96 |
|
type BearerCaller struct { |
| 97 |
|
// AuthContext is the resolved identity, never nil on a successful resolve. |
| 98 |
|
AuthContext *auth.AuthContext |
| 99 |
|
|
| 100 |
|
// InstanceToken reports which of the two bearer shapes this was: a |
| 101 |
|
// tokens.sr.ht working token (true) or a meta.sr.ht personal access token |
| 102 |
|
// (false). Only the ClientID distinguishes them — both are sealed with the |
| 103 |
|
// same instance key — and the difference decides which grant vocabulary |
| 104 |
|
// applies below. |
| 105 |
|
InstanceToken bool |
| 106 |
|
|
| 107 |
|
// Grants is the tokens.sr.ht grant set of a working token, parsed by |
| 108 |
|
// sr-ht-ecore/grants and by nothing else. It is the zero value — which |
| 109 |
|
// admits nothing — for a meta PAT, whose grants are in core-go's entirely |
| 110 |
|
// different OAuth vocabulary and live on AuthContext.Grants instead. Ask |
| 111 |
|
// Authorize rather than reading this field, so the distinction stays in one |
| 112 |
|
// place. |
| 113 |
|
Grants grants.Grants |
| 114 |
|
} |
| 115 |
|
|
| 116 |
|
// Authorize reports whether this caller may perform the named action, e.g. |
| 117 |
|
// core.GrantRead. |
| 118 |
|
// |
| 119 |
|
// A meta PAT passes unconditionally, and that is not a hole. It carries no |
| 120 |
|
// tokens.sr.ht grants at all — the vocabularies do not overlap — and its |
| 121 |
|
// scoping was already applied at resolve time, by the same TokenGrantsAllow |
| 122 |
|
// gate the clone path applies (docs/DESIGN.mcp.md §4.2: a meta PAT and an |
| 123 |
|
// anonymous caller pass this gate; their access is decided by core.Allowed). |
| 124 |
|
// Refusing it here would instead refuse every PAT on the surface, since no PAT |
| 125 |
|
// can ever be minted with a grant string tokens.sr.ht's parser would even read. |
| 126 |
|
// |
| 127 |
|
// A refusal wraps ErrMissingGrant: the credential is good and the caller is who |
| 128 |
|
// they say they are, and what is missing is a permission. |
| 129 |
4 |
func (c *BearerCaller) Authorize(grant string) error { |
| 130 |
4 |
if !c.InstanceToken { |
| 131 |
1 |
return nil |
| 132 |
1 |
} |
| 133 |
3 |
if !c.Grants.Has(grant) { |
| 134 |
1 |
return fmt.Errorf("%w: %q is not in %q", ErrMissingGrant, grant, c.Grants.String()) |
| 135 |
1 |
} |
| 136 |
2 |
return nil |
| 137 |
|
} |
| 138 |
|
|
| 139 |
|
// ResolveBearer resolves the caller for a bearer credential — the only machine |
| 140 |
|
// credential the /mcp surface accepts (docs/DESIGN.mcp.md §4.1). |
| 141 |
|
// |
| 142 |
|
// The instance issues two bearer shapes, both auth.BearerToken values sealed |
| 143 |
|
// with the same instance key, and only the ClientID tells them apart: |
| 144 |
|
// |
| 145 |
|
// - bearer.TokensClientID ⇒ a tokens.sr.ht working token. Verified through |
| 146 |
|
// sr-ht-ecore's validator (signature, version, expiry, ours-ness and, for a |
| 147 |
|
// registered token, the liveness check against the daemon), its owner |
| 148 |
|
// mirrored through the same MetaBackend the other planes use, and its grants |
| 149 |
|
// carried out on the result for BearerCaller.Authorize. |
| 150 |
|
// - anything else ⇒ a meta.sr.ht personal access token, resolved by |
| 151 |
|
// ResolveBasic — the decode/lookup/revocation path this package already has. |
| 152 |
|
// The one difference from the clone flow is that there is no presented |
| 153 |
|
// username to compare against, so the token's own username *is* the |
| 154 |
|
// identity. It is then gated by TokenGrantsAllow at core.AccessRO, the check |
| 155 |
|
// the clone path applies for a read. |
| 156 |
|
// |
| 157 |
|
// The ClientID is read here, by decoding the token once locally, rather than by |
| 158 |
|
// handing everything to Inspect and routing on bearer.ErrNotOurs. Both arms have |
| 159 |
|
// to work when there is no validator at all (see below), so the routing cannot |
| 160 |
|
// live inside the validator; and having it in one place beats having it twice. |
| 161 |
|
// The cost is one extra local HMAC on the working-token arm, which is the |
| 162 |
|
// cheapest step of the four. |
| 163 |
|
// |
| 164 |
|
// v may be nil, and that is a configuration rather than a degradation: an |
| 165 |
|
// instance whose config.ini has no [tokens.sr.ht] section has no such daemon. |
| 166 |
|
// Meta PATs and anonymity keep working; a working token is then refused with |
| 167 |
|
// ErrInvalidToken, because a machine credential this instance cannot verify is |
| 168 |
|
// refused and not guessed at. (A *typed* nil — (*bearer.Validator)(nil) in an |
| 169 |
|
// InstanceValidator — is not that contract and will panic; pass a plain nil.) |
| 170 |
|
// |
| 171 |
|
// Failure is a refusal and never a downgrade to anonymous. An empty presented |
| 172 |
|
// string is refused too: anonymity is the caller's decision, taken before this |
| 173 |
|
// function is reached (ParseBearer returning "" is what it is taken on), and an |
| 174 |
|
// empty credential arriving here is a caller that lost track of its own header. |
| 175 |
|
// |
| 176 |
|
// The error classes are backend.go's, unchanged: a permanent rejection wraps |
| 177 |
|
// ErrInvalidToken (401, additionally ErrMissingGrant for a 403), anything else |
| 178 |
|
// is a transient backend failure returned unwrapped (503). |
| 179 |
22 |
func ResolveBearer(ctx context.Context, v InstanceValidator, presented string) (*BearerCaller, error) { |
| 180 |
22 |
if presented == "" { |
| 181 |
1 |
return nil, fmt.Errorf("%w: no bearer token presented", ErrInvalidToken) |
| 182 |
1 |
} |
| 183 |
|
|
| 184 |
|
// Step 1 of the tokens SPEC for both arms at once: signature, version and |
| 185 |
|
// expiry, all local. A forged or expired credential costs one HMAC and never |
| 186 |
|
// becomes a request to meta.sr.ht or to tokens.sr.ht. |
| 187 |
21 |
bt := auth.DecodeBearerToken(presented) |
| 188 |
21 |
if bt == nil { |
| 189 |
3 |
return nil, fmt.Errorf("%w: token failed HMAC/expiry validation", ErrInvalidToken) |
| 190 |
3 |
} |
| 191 |
|
|
| 192 |
18 |
if bt.ClientID == bearer.TokensClientID { |
| 193 |
12 |
return resolveWorkingToken(ctx, v, presented) |
| 194 |
12 |
} |
| 195 |
6 |
return resolveMetaPAT(ctx, bt.Username, presented) |
| 196 |
|
} |
| 197 |
|
|
| 198 |
|
// resolveWorkingToken is the tokens.sr.ht arm. |
| 199 |
12 |
func resolveWorkingToken(ctx context.Context, v InstanceValidator, presented string) (*BearerCaller, error) { |
| 200 |
12 |
if v == nil { |
| 201 |
1 |
return nil, fmt.Errorf( |
| 202 |
1 |
"%w: this instance configures no [tokens.sr.ht] origin, so a working token cannot be verified", |
| 203 |
1 |
ErrInvalidToken) |
| 204 |
1 |
} |
| 205 |
|
|
| 206 |
11 |
tok, err := v.Inspect(ctx, presented) |
| 207 |
11 |
if err != nil { |
| 208 |
6 |
return nil, classifyInspect(err) |
| 209 |
6 |
} |
| 210 |
|
|
| 211 |
|
// The token names a meta.sr.ht account and nothing else; turning that into a |
| 212 |
|
// local row is the service's job (bearer's package doc says so, and the |
| 213 |
|
// tokens SPEC ch. 6 prescribes it for every service on the instance). It is |
| 214 |
|
// the same call the Basic path makes, through the same seam. |
| 215 |
5 |
var ac auth.AuthContext |
| 216 |
5 |
if err := meta.LookupUser(ctx, tok.Username, &ac); err != nil { |
| 217 |
1 |
// Transient: meta or the database could not answer. The credential is |
| 218 |
1 |
// good, and telling an agent to re-mint over a lookup outage is the wrong |
| 219 |
1 |
// instruction twice — it does not help, and it destroys a working token. |
| 220 |
1 |
return nil, fmt.Errorf("looking up user %q: %w", tok.Username, err) |
| 221 |
1 |
} |
| 222 |
4 |
if ac.UserID == 0 { |
| 223 |
1 |
// LookupUser answered without filling in an id. Nothing downstream can |
| 224 |
1 |
// use that: every ownership and ACL row keys on the user id, and a zero |
| 225 |
1 |
// would match the first repository whose owner id is unset. Permanent |
| 226 |
1 |
// rather than transient — retrying will not conjure the account back. |
| 227 |
1 |
return nil, fmt.Errorf("%w: working token names %q, for whom no meta id was mirrored", |
| 228 |
1 |
ErrInvalidToken, tok.Username) |
| 229 |
1 |
} |
| 230 |
|
|
| 231 |
3 |
ac.AuthMethod = AuthMethodInstanceToken |
| 232 |
3 |
// BearerToken and Grants are deliberately left unset. They are core-go's |
| 233 |
3 |
// OAuth fields, and filling them would subject this caller to |
| 234 |
3 |
// TokenGrantsAllow — a gate demanding "dolt.sr.ht/repos:RO", which a |
| 235 |
3 |
// tokens.sr.ht grant string can never spell. A working token is scoped by |
| 236 |
3 |
// its own vocabulary, on BearerCaller.Grants, and by core.Allowed. |
| 237 |
3 |
|
| 238 |
3 |
return &BearerCaller{ |
| 239 |
3 |
AuthContext: &ac, |
| 240 |
3 |
InstanceToken: true, |
| 241 |
3 |
Grants: tok.Grants, |
| 242 |
3 |
}, nil |
| 243 |
|
} |
| 244 |
|
|
| 245 |
|
// resolveMetaPAT is the meta.sr.ht arm: ResolveBasic with the token's own |
| 246 |
|
// username standing in for the presented one, plus the read gate. |
| 247 |
6 |
func resolveMetaPAT(ctx context.Context, username, presented string) (*BearerCaller, error) { |
| 248 |
6 |
// Passing the token's own username makes ResolveBasic's impersonation check |
| 249 |
6 |
// a tautology, which is correct here and only here: that check exists to |
| 250 |
6 |
// stop a token being used *as* another user's password, and there is no |
| 251 |
6 |
// second party's name in a bearer header to be checked against. Everything |
| 252 |
6 |
// else it does — the positive cache, the profile mirror, the revocation |
| 253 |
6 |
// check, the grant decode — is exactly what this arm needs, and is the |
| 254 |
6 |
// reason this is a call and not a copy. |
| 255 |
6 |
ac, err := ResolveBasic(ctx, username, presented) |
| 256 |
6 |
if err != nil { |
| 257 |
2 |
return nil, err |
| 258 |
2 |
} |
| 259 |
|
|
| 260 |
|
// The whole surface is a read, so the gate can be applied once here rather |
| 261 |
|
// than per action. It is the same check the clone path applies. |
| 262 |
4 |
if !TokenGrantsAllow(ac, core.AccessRO) { |
| 263 |
1 |
return nil, fmt.Errorf("%w: %w: token grants do not permit %s on %s repositories", |
| 264 |
1 |
ErrInvalidToken, ErrMissingGrant, core.AccessRO, RepoScope) |
| 265 |
1 |
} |
| 266 |
|
|
| 267 |
3 |
return &BearerCaller{AuthContext: ac, InstanceToken: false}, nil |
| 268 |
|
} |
| 269 |
|
|
| 270 |
|
// classifyInspect maps sr-ht-ecore's sentinels onto this package's two error |
| 271 |
|
// classes. It is the one place this service decides what each refusal of the |
| 272 |
|
// shared validator means here. |
| 273 |
|
// |
| 274 |
|
// The 401/503 split is the one that is easy to get wrong and expensive to get |
| 275 |
|
// wrong: an unreachable tokens.sr.ht must not read as a bad credential. "I could |
| 276 |
|
// not check" is not "your token is revoked", and answering 401 there would turn |
| 277 |
|
// a restart of a daemon deliberately kept off the hot path into every agent on |
| 278 |
|
// the instance being told to re-mint its credentials. |
| 279 |
|
// |
| 280 |
|
// bearer.ErrNotOurs is classified for totality and is not reachable: ResolveBearer |
| 281 |
|
// routes on the ClientID before Inspect is called, so a foreign token has already |
| 282 |
|
// gone to the meta arm. |
| 283 |
|
// |
| 284 |
|
// An unrecognised error is transient, which is the fail-closed direction here: a |
| 285 |
|
// sentinel this table has never seen must read as "I could not decide" — a 503 |
| 286 |
|
// the caller retries — never as a verdict about the credential. |
| 287 |
6 |
func classifyInspect(err error) error { |
| 288 |
6 |
switch { |
| 289 |
|
case errors.Is(err, bearer.ErrInvalid), |
| 290 |
|
errors.Is(err, bearer.ErrNotOurs), |
| 291 |
3 |
errors.Is(err, bearer.ErrRevoked): |
| 292 |
3 |
return fmt.Errorf("%w: %w", ErrInvalidToken, err) |
| 293 |
1 |
case errors.Is(err, bearer.ErrForbidden): |
| 294 |
1 |
// Not reachable through Inspect, which is not told an action; classified |
| 295 |
1 |
// so the table is total. Joined with ErrInvalidToken for the reason |
| 296 |
1 |
// ErrMissingGrant's own comment gives. |
| 297 |
1 |
return fmt.Errorf("%w: %w: %w", ErrInvalidToken, ErrMissingGrant, err) |
| 298 |
1 |
case errors.Is(err, bearer.ErrUnavailable): |
| 299 |
1 |
return fmt.Errorf("asking tokens.sr.ht whether a working token is live: %w", err) |
| 300 |
1 |
default: |
| 301 |
1 |
return fmt.Errorf("validating a tokens.sr.ht working token: %w", err) |
| 302 |
|
} |
| 303 |
|
} |