~bigbes/sr-ht-spec

ref: c74776077006e81af82c2fd53cb186271b42bee9 sr-ht-spec/authn/resolver.go -rw-r--r-- 7.4 KiB
c7477607 — Eugene Blikh authn: accept tokens.sr.ht working tokens beside the agent token 10 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
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 — the
// TokenStore local agent tokens are checked against, and, when the instance is
// configured for it, the tokens.sr.ht plane.
type Resolver struct {
	owner string
	store TokenStore

	// bearer and users are the instance plane, installed by WithInstancePlane.
	// Both are nil when the instance config has no [tokens.sr.ht] section, which
	// is a supported configuration and not a broken one: the plane is absent and
	// every bearer credential goes straight to the local store, exactly as it
	// did before this plane existed. Resolve tests bearer for presence, and
	// WithInstancePlane is what guarantees the two are wired together or not at
	// all.
	bearer BearerValidator
	users  UserLookup
}

// ResolverOption configures a Resolver at construction. Options rather than a
// second constructor because the instance plane is optional in production and
// not merely in tests: an instance without tokens.sr.ht must build the same
// resolver every other caller does.
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 one thing the instance plane adds over the local one.
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.
//
// A nil store is rejected rather than tolerated: with no store every agent
// token would resolve as unknown, which looks exactly like a mass revocation
// and is a miserable thing to debug at 2am. Wire a store or do not build a
// resolver.
//
// With no options the resolver knows only the local agent-token plane — what an
// instance with no [tokens.sr.ht] section gets, and what every caller got before
// that plane existed.
func NewResolver(owner string, store TokenStore, opts ...ResolverOption) (*Resolver, error) {
	owner = strings.TrimPrefix(owner, "~")
	if err := core.ValidateOwner(owner); err != nil {
		return nil, fmt.Errorf("authn: instance owner: %w", err)
	}
	if store == nil {
		return nil, fmt.Errorf("authn: nil TokenStore")
	}
	rs := &Resolver{owner: owner, store: store}
	for _, opt := range opts {
		if err := opt(rs); err != nil {
			return nil, err
		}
	}
	return rs, nil
}

// HasInstancePlane reports whether this resolver tries tokens.sr.ht before the
// local agent-token store. 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 is offered to the tokens.sr.ht plane first, when one
// is configured, and reaches the local agent-token store only if that plane
// says the string is not a token of the instance's. Which refusals mean that,
// and why the order is not the other way round, is in 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. IsAuthFailure separates the 401 case
//     (unknown, revoked, malformed) from the 503 case (store unreachable).
//
// 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 != "" {
		if rs.bearer != nil {
			p, fallBack, err := rs.resolveInstanceToken(ctx, r, presented)
			if !fallBack {
				return p, err
			}
		}
		tok, err := ResolveAgentToken(ctx, rs.store, presented)
		if err != nil {
			return Anonymous(), err
		}
		return Principal{
			Kind:      KindAgent,
			Owner:     rs.owner,
			Agent:     strings.TrimSpace(r.Header.Get(HeaderAgent)),
			Session:   strings.TrimSpace(r.Header.Get(HeaderAgentSession)),
			TokenName: tok.Name,
			Plane:     PlaneLocal,
		}, nil
	}

	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
}

// 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"
	}
}