~bigbes/sr-ht-spec

ref: bd5b615ab1fe6b50a20af8a9ebc2aa1180915823 sr-ht-spec/authn/bearer.go -rw-r--r-- 10.3 KiB
bd5b615a — Eugene Blikh docs: drop a stray closing tag from ci.md 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
220
221
222
223
224
225
226
package authn

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

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

// The grant vocabulary spec.sr.ht declares for tokens.sr.ht working tokens.
//
// The daemon that mints them does not know these strings and must not: the
// tokens.sr.ht spec gives the vocabulary to the services, so that adding an
// action to spec.sr.ht is a change to spec.sr.ht. An unknown grant simply
// admits nobody. They are constants rather than literals at the check because a
// grant is compared byte for byte — a typo in one of the two places it is
// spelled is a silent widening or a silent refusal, and neither shows up until
// it matters.
const (
	// ActionPropose is what an instance token must carry to write: open a
	// proposal or add documents to one.
	ActionPropose = "spec:propose"

	// ActionRead is what an instance token must carry to read content through
	// any of the read surfaces (the web UI, /query, MCP).
	ActionRead = "spec:read"
)

// BearerValidator is the sliver of sr-ht-ecore's bearer.Validator this package
// needs: who a presented working token belongs to, what it permits, and whether
// it is still live.
//
// Inspect and not Validate, because the resolver runs in middleware upstream of
// the router and so does not know which action is being attempted. The grant
// check happens where the action is known — service.Propose for the write
// plane, the read gates for the read plane — through Principal.Authorize.
//
// It is an interface rather than a *bearer.Validator so that this package stays
// testable without a tokens.sr.ht to talk to, exactly as TokenStore keeps it
// testable without a Postgres.
type BearerValidator interface {
	Inspect(ctx context.Context, presented string) (*bearer.Token, error)
}

// InstanceUser is the local "user" row that the owner of an instance token
// resolves to. It is the whole of what this package needs from that row: the id
// other layers key user-scoped state by, and the name it was found under.
type InstanceUser struct {
	ID       int
	Username string
}

// UserLookup resolves the meta.sr.ht username an instance token names into this
// service's local user row — core-go's auth.LookupUser in production.
//
// It is declared here for the same reason TokenStore is: that function reads a
// database handle and a config out of the context and panics without either,
// which is service/'s business to supply and not something a package answering
// "who is making this request?" should carry. service/ wires the real one in;
// tests wire a map.
type UserLookup interface {
	LookupUser(ctx context.Context, username string) (InstanceUser, error)
}

// resolveInstanceToken runs the tokens.sr.ht plane against a presented bearer
// credential. Its answer is final: there is one agent credential plane, so a
// refusal here is the service's refusal.
//
// It used to report a third thing — whether the caller should fall back to
// spec's own agent_token store — and exactly two refusals said yes:
//
//   - bearer.ErrInvalid, because spec's local token was 32 random bytes in
//     base64, which is precisely what "did not decode as one of ours" looks
//     like;
//   - bearer.ErrNotOurs, because refusing a meta.sr.ht PAT was the local plane's
//     business rather than this one's, and falling through cost one hash lookup
//     that would miss.
//
// With that store gone both are plain refusals. The one consequence worth
// naming is ErrNotOurs: IsAuthFailure now counts it permanent, so a meta PAT
// presented here earns a 401 rather than the 503 an unclassified error would.
//
// The rest of the mapping is unchanged and lives in StatusFor: ErrInvalid and
// ErrRevoked are 401, ErrForbidden and a foreign owner are 403, and
// ErrUnavailable is 503 — never 401, because "I could not ask tokens.sr.ht" is
// not "your token is bad".
func (rs *Resolver) resolveInstanceToken(
	ctx context.Context, presented, agent, session string,
) (Principal, error) {
	tok, err := rs.bearer.Inspect(ctx, presented)
	if err != nil {
		return Anonymous(), fmt.Errorf("authn: instance token: %w", err)
	}

	// The token names a meta.sr.ht account, and spec.sr.ht has exactly one that
	// means anything. This is the same rule the cookie plane already applies —
	// a real user who is not the instance owner reads as nobody — and applying
	// it here keeps every consumer of Principal.Owner honest: the provenance
	// committer, the refs rule's principal kind and coreauth's AuthContext all
	// assume the human an agent acts for is the instance owner, and a foreign
	// name would make each of them quietly wrong in a different way.
	//
	// It is a refusal rather than a downgrade to anonymous because a presented
	// credential that fails must fail at the door: the asymmetry this package's
	// doc comment draws between cookies and bearer tokens.
	username := strings.TrimPrefix(tok.Username, "~")
	if username != rs.owner {
		return Anonymous(), fmt.Errorf(
			"%w: the token belongs to ~%s, and this instance answers only to ~%s",
			ErrNotInstanceOwner, username, rs.owner)
	}

	// The owner is resolved to a local row even though single-user spec could
	// infer it: the row id is what user-scoped state keys off, and looking it up
	// here is what makes the instance plane's identity a fact about this
	// database rather than a name copied out of a signed blob.
	user, err := rs.users.LookupUser(ctx, username)
	if err != nil {
		// Unclassified, therefore transient, therefore 503: a database that
		// cannot answer must never read as a bad credential.
		return Anonymous(), fmt.Errorf("authn: resolve instance token owner ~%s: %w", username, err)
	}

	return Principal{
		Kind:      KindAgent,
		Owner:     rs.owner,
		Agent:     agent,
		Session:   session,
		TokenName: instanceTokenLabel(tok),
		Plane:     PlaneInstance,
		Grants:    tok.Grants,
		UserID:    user.ID,
	}, nil
}

// instanceTokenLabel names the credential in a log line. A registered token has
// a row at tokens.sr.ht an operator can find and revoke, so its id is the useful
// thing to print; a stateless one was never written down, and saying so is more
// honest than printing "0".
func instanceTokenLabel(tok *bearer.Token) string {
	if tok.Registered() {
		return "tokens.sr.ht #" + strconv.Itoa(tok.TokenID)
	}
	return "tokens.sr.ht (stateless)"
}

// StatusFor maps an error out of Resolve — or out of a later Authorize — onto
// the status the surface must answer with. It is one function so that the three
// surfaces cannot each invent their own table.
//
// Everything the credential itself can be wrong about is bearer.StatusFor's
// answer, not a second copy of it: ErrForbidden is 403, ErrUnavailable is 503
// and never 401, and ErrInvalid, ErrRevoked and ErrNotOurs are 401. That last
// arm is only reached because ErrNotOurs is decided before we ask — a meta.sr.ht
// PAT used to fall through to spec's own token store, and with that store gone
// it is a refusal at the door.
//
// The ErrUnavailable line is the one worth restating even though it is no longer
// spelled here. Reading "I could not reach tokens.sr.ht" as "your token is
// revoked" would refuse every live instance token for as long as a daemon that
// is deliberately off the hot path takes to restart, and would tell a thousand
// clients their credentials are bad when the truth is that one service is down.
//
// What this function adds is what bearer cannot know:
//
//   - ErrMissingGrant and ErrNotInstanceOwner are 403. The credential verifies
//     and the holder is who they say they are, so retrying is pointless and what
//     they need is a wider grant, not another login. Both are asked before the
//     bearer table, because ErrMissingGrant is raised beside a token that
//     verified and must not be read as one that did not.
//   - Whatever else IsAuthFailure calls permanent is 401 — today that is
//     ErrNoToken, nothing having been presented on a surface that requires a
//     credential. The predicate is asked rather than the sentinel listed a second
//     time, so that a sentinel added to one of them cannot be missing from the
//     other: this package's two answers to "is the credential the problem?" have
//     to agree, and the cheapest way to guarantee that is for one to be built
//     from the other.
//   - ErrNoAgentPlane, and anything else at all, is 503. An instance with no
//     [tokens.sr.ht] origin cannot check any credential, and telling the holder
//     of a good token that it is bad would send them to re-provision it; an
//     unclassified error is a backend that could not answer. This is where the
//     two tables' defaults deliberately differ — bearer's unrecognised failure
//     is the caller's credential, because everything reaching it is about a
//     credential, while an unrecognised failure here can be the database this
//     resolver had to consult, which must never read as a bad token.
func StatusFor(err error) int {
	switch {
	case err == nil:
		return http.StatusOK
	case errors.Is(err, ErrMissingGrant), errors.Is(err, ErrNotInstanceOwner):
		return http.StatusForbidden
	case isBearerRefusal(err):
		return bearer.StatusFor(err)
	case IsAuthFailure(err):
		return http.StatusUnauthorized
	default:
		return http.StatusServiceUnavailable
	}
}

// isBearerRefusal reports whether err is one of the sentinels bearer.StatusFor
// has an answer for. The list is here rather than in a helper over there because
// it is the question "did the shared validator decide this?", and a wrong answer
// to it is what would let the 503 default below swallow a 401 — or, worse, let
// bearer's own 401 default swallow a database outage.
func isBearerRefusal(err error) bool {
	return errors.Is(err, bearer.ErrForbidden) ||
		errors.Is(err, bearer.ErrUnavailable) ||
		errors.Is(err, bearer.ErrInvalid) ||
		errors.Is(err, bearer.ErrRevoked) ||
		errors.Is(err, bearer.ErrNotOurs)
}

// Challenge is the WWW-Authenticate value every 401 this service answers must
// carry, per RFC 9110 §11.6.1 — the scheme, and this service's config section as
// the realm, which is what names it in the config, in the nav and in a grant
// everywhere else on the instance.
//
// It is bearer.Challenge with our section already in it, so that the four
// surfaces that refuse a credential (the resolver's middleware, MCP, /query and
// the read plane's machine formats) cannot name four realms.
func Challenge() string { return bearer.Challenge(ConfigSection) }