~bigbes/sr-ht-spec

ref: a4d8cc52e917cf1db07c876e8b36654dcf28741b sr-ht-spec/authn/resolver.go -rw-r--r-- 8.3 KiB
a4d8cc52 — Eugene Blikh authn: remove the local agent-token plane 9 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
package authn

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

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

// 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 := UsernameFromRequest(r)
	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.
					log.Printf("authn: resolving bearer credential: %v", err)
				}
				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"
	}
}