| 1 |
|
package authn |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"errors" |
| 6 |
|
"fmt" |
| 7 |
|
"strings" |
| 8 |
|
|
| 9 |
|
"sourcecraft.dev/bigbes/sr-ht-core/auth" |
| 10 |
|
|
| 11 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/metapat" |
| 12 |
|
|
| 13 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/core" |
| 14 |
|
) |
| 15 |
|
|
| 16 |
|
// ScopeRead is the OAuth grant a meta.sr.ht personal access token must carry to |
| 17 |
|
// read through this service: "spec.sr.ht/SPECS", at :RO or better. |
| 18 |
|
// |
| 19 |
|
// It is meta.sr.ht's vocabulary and it is not ActionRead spelled differently. |
| 20 |
|
// The two grammars share a token format and nothing else: ActionRead |
| 21 |
|
// ("spec:read") is what tokens.sr.ht seals into a working token, and no PAT can |
| 22 |
|
// carry it because meta's personal-token page cannot spell it; ScopeRead is a |
| 23 |
|
// checkbox on that page, and no working token can carry it because ecore's |
| 24 |
|
// grants parser does not read that shape at all. A credential has one or the |
| 25 |
|
// other and never both, which is why the two planes are asked different |
| 26 |
|
// questions about the same request (Principal.Authorize, metapat.Allows). |
| 27 |
|
// |
| 28 |
|
// The service half is ConfigSection, so a rename breaks the build here rather |
| 29 |
|
// than leaving behind a scope nobody can be granted. The bare half — "SPECS" — |
| 30 |
|
// is what api-meta.json publishes, because meta prefixes the service name |
| 31 |
|
// itself; cmd/specsrht derives that list from this constant rather than spelling |
| 32 |
|
// it a second time, and a test on each side asserts the two agree. A scope |
| 33 |
|
// published and not checked admits what should have been refused; one checked |
| 34 |
|
// and not published cannot be minted at all; neither failure is visible from |
| 35 |
|
// inside a single file. |
| 36 |
|
const ScopeRead = ConfigSection + "/SPECS" |
| 37 |
|
|
| 38 |
|
// MetaValidator is the sliver of sr-ht-ecore's metapat.Validator this plane |
| 39 |
|
// needs. |
| 40 |
|
// |
| 41 |
|
// The interface is declared in the consumer, as BearerValidator and UserLookup |
| 42 |
|
// are and for the same reasons: it states exactly how much of the shared |
| 43 |
|
// validator this package depends on — one method — and it is what keeps authn |
| 44 |
|
// testable with no meta.sr.ht, no network and no Postgres. The concrete |
| 45 |
|
// validator reaches core-go's auth.LookupUser, which reads a database handle out |
| 46 |
|
// of the context and panics without one; holding it behind an interface is what |
| 47 |
|
// keeps that dependency in cmd/ where the wiring lives, exactly as this package's |
| 48 |
|
// doc comment promises. |
| 49 |
|
type MetaValidator interface { |
| 50 |
|
// Resolve verifies the signature and expiry locally, refuses a token sealed |
| 51 |
|
// by tokens.sr.ht, mirrors the owner's profile and asks meta.sr.ht whether |
| 52 |
|
// the token has been revoked. It answers with core-go's own OAuth2 caller, or |
| 53 |
|
// with one of the metapat package's sentinels. |
| 54 |
|
Resolve(ctx context.Context, presented string) (*auth.AuthContext, error) |
| 55 |
|
} |
| 56 |
|
|
| 57 |
|
// Compile-time proof that the shared validator satisfies the port. It is what |
| 58 |
|
// lets this package depend on the interface rather than on *metapat.Validator, |
| 59 |
|
// and it fails the build the moment either side drifts. |
| 60 |
|
var _ MetaValidator = (*metapat.Validator)(nil) |
| 61 |
|
|
| 62 |
|
// MetaAuth is the meta.sr.ht plane: an ordinary personal access token, the |
| 63 |
|
// credential every upstream service on this instance already accepts. |
| 64 |
|
// |
| 65 |
|
// # Why spec.sr.ht accepts one at all |
| 66 |
|
// |
| 67 |
|
// It did not, and the reason it does now is the gateway. api.sr.ht forwards ONE |
| 68 |
|
// client "Authorization" header to every service a federated query touches — its |
| 69 |
|
// AuthMiddleware copies the client's header verbatim into the request context, |
| 70 |
|
// and the internal credential it can mint is used only to fetch schemas at |
| 71 |
|
// startup — so a federated caller arrives holding whatever credential the client |
| 72 |
|
// had. The only credential that works across the whole instance is a meta PAT, |
| 73 |
|
// so an endpoint that refuses them answers 401 to the first authenticated |
| 74 |
|
// federated query and can never be part of the gateway's schema, however correct |
| 75 |
|
// each individual refusal looks. |
| 76 |
|
// |
| 77 |
|
// # Where it is and is not accepted |
| 78 |
|
// |
| 79 |
|
// This plane is deliberately NOT part of Resolver. It is reachable from /query |
| 80 |
|
// alone, which is the only surface with the federation problem; the REST write |
| 81 |
|
// plane, /mcp and the push hook keep asking for a tokens.sr.ht working token, |
| 82 |
|
// where a narrow, short-lived, revocable grant is worth what it costs an agent to |
| 83 |
|
// obtain. Nothing on those paths holds a MetaAuth, so nothing on them can produce |
| 84 |
|
// a PlaneMeta principal, and a PAT is not a way around a grant. |
| 85 |
|
// |
| 86 |
|
// # What it is left deciding |
| 87 |
|
// |
| 88 |
|
// It owns no crypto and no lookups: verification is ecore's metapat, one copy of |
| 89 |
|
// that check for every service on the instance. Three questions are this |
| 90 |
|
// service's own and are why the type exists at all — whether the token's owner is |
| 91 |
|
// the one human this instance answers to, whether it carries the scope, and which |
| 92 |
|
// failures of the shared validator are a bad credential, which are a missing |
| 93 |
|
// permission, and which are an outage. |
| 94 |
|
type MetaAuth struct { |
| 95 |
|
validator MetaValidator |
| 96 |
|
owner string |
| 97 |
|
scope string |
| 98 |
|
} |
| 99 |
|
|
| 100 |
|
// NewMetaAuth builds the plane over a validator, the instance owner username |
| 101 |
|
// from [sr.ht] owner-name, and the OAuth scope a token must carry to read |
| 102 |
|
// through this service. |
| 103 |
|
// |
| 104 |
|
// Each argument is refused rather than tolerated when it is empty, and the three |
| 105 |
|
// reasons are different: |
| 106 |
|
// |
| 107 |
|
// - A nil validator is not the "this instance has no such plane" configuration. |
| 108 |
|
// That one is a nil *MetaAuth, which graph.New refuses outright because the |
| 109 |
|
// plane needs no per-instance origin and so can never be legitimately absent. |
| 110 |
|
// A validator-less plane would fail every PAT while looking configured. |
| 111 |
|
// - An empty owner would compare every token's account against "", so either no |
| 112 |
|
// PAT would ever be admitted, or — worse, if the comparison were ever loosened |
| 113 |
|
// — the check that makes this a single-owner instance would be the one that |
| 114 |
|
// silently did nothing. NewResolver validates its owner for the same reason |
| 115 |
|
// and with the same rule, so the two planes cannot disagree about who bigbes |
| 116 |
|
// is. |
| 117 |
|
// - An empty scope would have metapat.Allows asked about the grant name "", |
| 118 |
|
// which no token carries and no meta checkbox can mint, so every PAT on the |
| 119 |
|
// instance would be refused with a message naming a permission that does not |
| 120 |
|
// exist. |
| 121 |
25 |
func NewMetaAuth(validator MetaValidator, owner, scope string) (*MetaAuth, error) { |
| 122 |
25 |
if validator == nil { |
| 123 |
1 |
return nil, fmt.Errorf("authn: nil MetaValidator") |
| 124 |
1 |
} |
| 125 |
24 |
owner = strings.TrimPrefix(owner, "~") |
| 126 |
24 |
if err := core.ValidateOwner(owner); err != nil { |
| 127 |
2 |
return nil, fmt.Errorf("authn: meta plane instance owner: %w", err) |
| 128 |
2 |
} |
| 129 |
22 |
if scope == "" { |
| 130 |
1 |
return nil, fmt.Errorf("authn: empty OAuth scope, e.g. %s", ScopeRead) |
| 131 |
1 |
} |
| 132 |
21 |
return &MetaAuth{validator: validator, owner: owner, scope: scope}, nil |
| 133 |
|
} |
| 134 |
|
|
| 135 |
|
// Scope is the OAuth grant this plane requires, for a caller that wants to name |
| 136 |
|
// it in a refusal without spelling it a second time. |
| 137 |
1 |
func (a *MetaAuth) Scope() string { return a.scope } |
| 138 |
|
|
| 139 |
|
// Owner is the instance owner username this plane admits tokens for. |
| 140 |
2 |
func (a *MetaAuth) Owner() string { return a.owner } |
| 141 |
|
|
| 142 |
|
// VerifyToken validates a presented personal access token and returns the |
| 143 |
|
// principal of its owner. |
| 144 |
|
// |
| 145 |
|
// presented is the bare credential, with the "Bearer " scheme already stripped. |
| 146 |
|
// |
| 147 |
|
// The principal is KindAgent, never KindOwner, even though the token belongs to |
| 148 |
|
// the instance owner's own meta.sr.ht account. That is the security decision of |
| 149 |
|
// this whole change: KindOwner may approve proposals and manage webhooks, and it |
| 150 |
|
// is reached by a unified-login cookie — a browser session a human is sitting in |
| 151 |
|
// front of — while a PAT is a bearer string any process holding it can present, |
| 152 |
|
// forwarded through a gateway by whatever client asked. Reading one as the owner |
| 153 |
|
// would hand the approved branch to the widest credential on the instance. |
| 154 |
|
// |
| 155 |
|
// It carries no Grants either: those are tokens.sr.ht's vocabulary and a PAT is |
| 156 |
|
// in meta's, which is checked here instead — once, against the scope this plane |
| 157 |
|
// was built with, because the whole surface it guards is a read. That is also why |
| 158 |
|
// Principal.Authorize passes a PlaneMeta caller through: asking it for a grant a |
| 159 |
|
// PAT can never carry would refuse every one of them. |
| 160 |
|
// |
| 161 |
|
// Agent identity and session are left empty, and this plane is given the |
| 162 |
|
// credential rather than the request precisely so that they cannot be otherwise. |
| 163 |
|
// Provenance is demanded at a write (AgentWrite.Validate) and /query performs |
| 164 |
|
// none — its only mutations are the webhook ones, which webhookAuthorized |
| 165 |
|
// restricts to KindOwner — so there is nothing here to attribute. A plane handed |
| 166 |
|
// the request could also read a cookie off it, which would quietly reintroduce |
| 167 |
|
// the ambient authority this endpoint exists without. |
| 168 |
|
// |
| 169 |
|
// The failures are this package's sentinels, so that graph turns them into |
| 170 |
|
// statuses through the same StatusFor table it already uses for the other plane. |
| 171 |
19 |
func (a *MetaAuth) VerifyToken(ctx context.Context, presented string) (Principal, error) { |
| 172 |
19 |
if presented == "" { |
| 173 |
1 |
return Anonymous(), ErrNoToken |
| 174 |
1 |
} |
| 175 |
|
|
| 176 |
18 |
ac, err := a.validator.Resolve(ctx, presented) |
| 177 |
18 |
if err != nil { |
| 178 |
12 |
return Anonymous(), classifyResolve(err) |
| 179 |
12 |
} |
| 180 |
6 |
if ac.UserID == 0 { |
| 181 |
1 |
// metapat refuses this itself; the check is kept because the seam is an |
| 182 |
1 |
// interface, and a validator that answered with an empty context must not |
| 183 |
1 |
// produce a principal that owns whichever row has an unset owner id. |
| 184 |
1 |
return Anonymous(), fmt.Errorf( |
| 185 |
1 |
"%w: it resolved to no meta.sr.ht user id", ErrInvalidPersonalToken) |
| 186 |
1 |
} |
| 187 |
|
|
| 188 |
|
// The token names a meta.sr.ht account, and spec.sr.ht has exactly one that |
| 189 |
|
// means anything. This is the rule resolveInstanceToken applies to a working |
| 190 |
|
// token and the cookie plane applies to a session, and it matters most here: |
| 191 |
|
// a PAT is the credential every account on the instance can mint for itself, |
| 192 |
|
// so without this check the widest credential in existence would be the one |
| 193 |
|
// that skipped the narrowest identity rule, and any user of the instance could |
| 194 |
|
// read the whole corpus through /query. |
| 195 |
|
// |
| 196 |
|
// It is a refusal rather than a downgrade to anonymous because a presented |
| 197 |
|
// credential that fails must fail at the door: the asymmetry this package's |
| 198 |
|
// doc comment draws between cookies and bearer tokens. |
| 199 |
5 |
username := strings.TrimPrefix(ac.Username, "~") |
| 200 |
5 |
if username != a.owner { |
| 201 |
1 |
return Anonymous(), fmt.Errorf( |
| 202 |
1 |
"%w: the personal access token belongs to ~%s, and this instance answers only to ~%s", |
| 203 |
1 |
ErrNotInstanceOwner, username, a.owner) |
| 204 |
1 |
} |
| 205 |
|
|
| 206 |
4 |
if !metapat.Allows(ac, a.scope, auth.RO) { |
| 207 |
1 |
return Anonymous(), fmt.Errorf( |
| 208 |
1 |
"%w: this personal access token does not carry %s", ErrMissingScope, a.scope) |
| 209 |
1 |
} |
| 210 |
|
|
| 211 |
3 |
return Principal{ |
| 212 |
3 |
Kind: KindAgent, |
| 213 |
3 |
Owner: a.owner, |
| 214 |
3 |
// Diagnostics only, as on the other plane. There is no row id to print: |
| 215 |
3 |
// meta.sr.ht does not number a PAT the way tokens.sr.ht numbers a working |
| 216 |
3 |
// token, and naming the plane is the useful half anyway — it says which of |
| 217 |
3 |
// two credentials a log line is about. |
| 218 |
3 |
TokenName: "meta.sr.ht personal access token", |
| 219 |
3 |
Plane: PlaneMeta, |
| 220 |
3 |
UserID: ac.UserID, |
| 221 |
3 |
}, nil |
| 222 |
|
} |
| 223 |
|
|
| 224 |
|
// classifyResolve maps metapat's sentinels onto this package's, and is the one |
| 225 |
|
// place spec.sr.ht decides what each refusal of the shared PAT validator means |
| 226 |
|
// here. |
| 227 |
|
// |
| 228 |
|
// It is deliberately the same shape as the tokens.sr.ht plane's mapping, arm for |
| 229 |
|
// arm — resolveInstanceToken wraps bearer's sentinels and StatusFor reads them — |
| 230 |
|
// because the two planes owe the surfaces above the same three answers, 401, 403 |
| 231 |
|
// and 503. A difference between the tables would be a difference in what a client |
| 232 |
|
// is told about the same kind of failure, decided by which credential it happened |
| 233 |
|
// to be holding. |
| 234 |
|
// |
| 235 |
|
// metapat.ErrNotOurs is classified for totality and is not reachable in |
| 236 |
|
// production: graph routes on metapat.PlaneOf before this plane is asked, so a |
| 237 |
|
// working token has already gone to the other one. Were it ever to arrive here it |
| 238 |
|
// is a bad credential *for this plane*, and 401 is the honest answer. |
| 239 |
|
// |
| 240 |
|
// An unrecognised error is ErrMetaUnavailable, and that is the fail-closed |
| 241 |
|
// direction rather than a shrug: a sentinel this table has never seen must read |
| 242 |
|
// as "I could not decide" — a 503 the caller retries — and never as a verdict |
| 243 |
|
// about the credential. Answering 401 to something this function does not |
| 244 |
|
// understand would tell a client to re-mint a token that may be perfectly good. |
| 245 |
12 |
func classifyResolve(err error) error { |
| 246 |
12 |
switch { |
| 247 |
|
case errors.Is(err, metapat.ErrInvalid), |
| 248 |
|
errors.Is(err, metapat.ErrNotOurs), |
| 249 |
6 |
errors.Is(err, metapat.ErrRevoked): |
| 250 |
6 |
return fmt.Errorf("%w: %w", ErrInvalidPersonalToken, err) |
| 251 |
2 |
case errors.Is(err, metapat.ErrForbidden): |
| 252 |
2 |
// Not reachable through Resolve, which is never told a scope; classified |
| 253 |
2 |
// so that the table is total. |
| 254 |
2 |
return fmt.Errorf("%w: %w", ErrMissingScope, err) |
| 255 |
2 |
case errors.Is(err, metapat.ErrUnavailable): |
| 256 |
2 |
// meta.sr.ht and not tokens.sr.ht, which is the entire reason this |
| 257 |
2 |
// sentinel exists beside bearer.ErrUnavailable rather than reusing it. |
| 258 |
2 |
return fmt.Errorf("%w: %w", ErrMetaUnavailable, err) |
| 259 |
2 |
default: |
| 260 |
2 |
return fmt.Errorf("%w: validating a meta.sr.ht personal access token: %w", |
| 261 |
2 |
ErrMetaUnavailable, err) |
| 262 |
|
} |
| 263 |
|
} |