~bigbes/sr-ht-spec

ref: ef7bddf8b3d64cb4c204a064ad73dbacc78443cb sr-ht-spec/authn/resolver.go -rw-r--r-- 10.2 KiB
ef7bddf8 — Eugene Blikh ci: publish the apk into artifacts.sr.ht as well 3 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
package authn

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"strings"

	"go.bigb.es/auxilia/scribe"
	"sourcecraft.dev/bigbes/sr-ht-ecore/login"

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

// cookieDecode is how this service reads the instance's unified-login cookie:
// sr-ht-ecore's one decoder, told to use core.ValidateOwner as its name rule.
//
// The decode itself is not ours and never was — decrypt without expiration,
// unmarshal core-go's claims, strip one leading '~', treat every failure as
// anonymity — and six services each keeping a copy of those four steps is how
// one of them ends up missing the fifth. The fifth is the validator, and it is
// the one thing here that stays spec.sr.ht's: a decoded name goes on to be
// joined into a repository path under [spec.sr.ht] repos, and core.ValidateOwner
// is the rule the rest of this service builds those paths against. Passing it
// means there is one grammar rather than two that agree until one of them is
// widened.
//
// Resolved once, at package level, because it is read on every request and
// login.Option is a build step.
var cookieDecode = []login.Option{login.WithValidator(validCookieName)}

// validCookieName adapts core.ValidateOwner to login's predicate shape. An
// error is "not a usable identity", which login turns into an anonymous viewer
// — never an error page, because a name this service cannot use is the same
// thing to a browser as no cookie at all.
func validCookieName(name string) bool { return core.ValidateOwner(name) == nil }

// Resolver turns a request into a Principal. It holds the instance owner
// username — the one name a cookie has to match to carry authority — and, when
// the instance is configured for it, the tokens.sr.ht plane every agent
// credential is checked against.
type Resolver struct {
	owner string

	// bearer and users are the agent plane, installed by WithInstancePlane. Both
	// are nil when the instance config has no [tokens.sr.ht] section, and a
	// resolver in that state authenticates no agent at all: since spec stopped
	// minting its own credential there is nothing else for a bearer token to be
	// checked against. It is still a legal resolver — the CLI paths that
	// authenticate nobody build one — but a bearer credential presented to it is
	// a hard ErrNoAgentPlane, never a shrug.
	bearer BearerValidator
	users  UserLookup
}

// ResolverOption configures a Resolver at construction. Options rather than a
// second constructor because the plane is genuinely absent in some processes:
// `specsrht doc` builds a Service, resolves nobody, and has no use for an HTTP
// client to tokens.sr.ht.
type ResolverOption func(*Resolver) error

// WithInstancePlane wires the tokens.sr.ht bearer plane in: v validates a
// presented working token, users resolves its owner to a local row.
//
// Both are required together. A validator with no way to resolve an owner would
// authenticate a token and then have nothing to say about who presented it,
// which is the whole of what an agent credential is for here.
func WithInstancePlane(v BearerValidator, users UserLookup) ResolverOption {
	return func(rs *Resolver) error {
		if v == nil {
			return fmt.Errorf("authn: nil BearerValidator")
		}
		if users == nil {
			return fmt.Errorf("authn: nil UserLookup")
		}
		rs.bearer = v
		rs.users = users
		return nil
	}
}

// NewResolver builds a Resolver for the instance owner named in
// [sr.ht] owner-name.
//
// Pass WithInstancePlane to give it an agent plane. Without one it resolves
// cookies and refuses every bearer credential with ErrNoAgentPlane; the daemon
// therefore builds one with the plane and fails startup if it cannot, while the
// CLI paths that authenticate nobody build one without.
func NewResolver(owner string, opts ...ResolverOption) (*Resolver, error) {
	owner = strings.TrimPrefix(owner, "~")
	if err := core.ValidateOwner(owner); err != nil {
		return nil, fmt.Errorf("authn: instance owner: %w", err)
	}
	rs := &Resolver{owner: owner}
	for _, opt := range opts {
		if err := opt(rs); err != nil {
			return nil, err
		}
	}
	return rs, nil
}

// HasInstancePlane reports whether this resolver can authenticate an agent at
// all. Startup logging and tests only; never an authorization input.
func (rs *Resolver) HasInstancePlane() bool { return rs.bearer != nil }

// Owner returns the instance owner username this resolver recognises.
func (rs *Resolver) Owner() string { return rs.owner }

// Resolve determines who is making a request.
//
// A bearer token wins over a cookie when both are present: an agent that went
// to the trouble of presenting a credential is asking to be treated as an
// agent, and letting a stale browser cookie promote it to the owner would hand
// it the approved branch. The two credentials are checked in that order and
// never merged.
//
// A presented bearer token goes to the tokens.sr.ht plane and nowhere else.
// There is no second store behind it since spec stopped minting its own
// credential, so every refusal that plane returns is final — see
// resolveInstanceToken.
//
// The error contract is asymmetric on purpose:
//
//   - No bearer token: never an error. The cookie decides between KindOwner and
//     KindAnonymous, and any cookie problem is anonymity, not failure.
//   - A bearer token that fails: an error. StatusFor separates the 401 case
//     (malformed, foreign, revoked) from the 403 case (a good token this
//     instance has nothing to grant) and the 503 case (tokens.sr.ht
//     unreachable, or no plane wired at all).
//
// The agent identity and session headers are read here but not required: they
// are demanded at the write, by AgentWrite.Validate, which is the only place
// the design requires them and the only place a missing one can do harm.
func (rs *Resolver) Resolve(ctx context.Context, r *http.Request) (Principal, error) {
	if presented := BearerFromRequest(r); presented != "" {
		return rs.ResolveAgent(ctx, presented,
			r.Header.Get(HeaderAgent), r.Header.Get(HeaderAgentSession))
	}

	username := login.UsernameFromRequest(r, cookieDecode...)
	if username == "" {
		return Anonymous(), nil
	}
	if username != rs.owner {
		// A real user of the instance who is not bigbes. Single-user means
		// there is nothing to grant them, so they read exactly as an anonymous
		// viewer does; the name is kept for the log line and the "you are
		// signed in as" affordance only.
		return Principal{Kind: KindAnonymous, CookieUser: username}, nil
	}
	return Principal{Kind: KindOwner, Owner: username, CookieUser: username}, nil
}

// ResolveAgent authenticates a presented agent credential, with the provenance
// the caller collected alongside it, and is what Resolve calls once it has
// pulled all three out of an HTTP request.
//
// It is exported because the push path is not an HTTP request: a `git push`
// arrives over SSH and the credential reaches the daemon in a hook's
// environment, not in an Authorization header. That path used to check the
// agent_token table directly, which is precisely how it ended up unable to
// accept an instance token while the HTTP surfaces could. One credential plane
// deserves one implementation of "is this credential good?", so hooks calls this
// and the two surfaces cannot drift.
//
// It never returns an anonymous principal on failure: a presented credential
// that does not verify is an error, so the caller refuses at the door instead of
// silently downgrading an agent to a reader.
func (rs *Resolver) ResolveAgent(ctx context.Context, presented, agent, session string) (Principal, error) {
	if presented == "" {
		return Anonymous(), ErrNoToken
	}
	if rs.bearer == nil {
		// Not a bad credential: this process cannot check any credential. 503
		// via StatusFor, and the operator's clue is in the message rather than
		// in an agent's incident report about a token that "stopped working".
		return Anonymous(), fmt.Errorf(
			"%w: spec.sr.ht authenticates agents through tokens.sr.ht, and this instance's "+
				"config.ini has no [tokens.sr.ht] origin", ErrNoAgentPlane)
	}
	return rs.resolveInstanceToken(ctx, presented,
		strings.TrimSpace(agent), strings.TrimSpace(session))
}

// Middleware attaches the resolved Principal to the request context, where
// PrincipalFromContext reads it.
//
// It rejects only a failed bearer token — 401 for a bad credential, 403 for a
// good one that this instance has nothing to grant, 503 for a backend that could
// not answer, per StatusFor. Everything else, including every cookie problem,
// flows through as anonymous: the read plane is anonymous-capable and must never
// answer an error page on identity grounds.
func (rs *Resolver) Middleware() func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			p, err := rs.Resolve(r.Context(), r)
			if err != nil {
				status := StatusFor(err)
				if status >= 500 {
					// Fail closed and loudly. The alternative — degrading to
					// anonymous — would turn a Postgres blip or an unreachable
					// tokens.sr.ht into agents silently losing their write
					// access.
					slog.ErrorContext(r.Context(), "resolving an agent credential failed",
						"method", r.Method, "path", r.URL.Path, "status", status,
						scribe.Err(err))
				}
				if status == http.StatusUnauthorized {
					// RFC 9110 requires the challenge on a 401, and the caller
					// here is always a machine holding a bearer token: naming the
					// scheme and the realm is what tells it which credential this
					// service was refusing.
					w.Header().Set("WWW-Authenticate", Challenge())
				}
				http.Error(w, resolveFailureMessage(status), status)
				return
			}
			next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), p)))
		})
	}
}

// resolveFailureMessage is what a refused caller is told. It is keyed on the
// status and not on the error, so that nothing about which plane refused, whose
// token it was, or whether a row exists leaks to a caller holding a credential
// this service did not accept.
func resolveFailureMessage(status int) string {
	switch status {
	case http.StatusUnauthorized:
		return "invalid agent token"
	case http.StatusForbidden:
		return "this token does not authorize requests to spec.sr.ht"
	default:
		return "authentication backend unavailable"
	}
}