| 1 |
|
package authn |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"fmt" |
| 6 |
|
"log/slog" |
| 7 |
|
"net/http" |
| 8 |
|
"strings" |
| 9 |
|
|
| 10 |
|
"go.bigb.es/auxilia/scribe" |
| 11 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/login" |
| 12 |
|
|
| 13 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/core" |
| 14 |
|
) |
| 15 |
|
|
| 16 |
|
// cookieDecode is how this service reads the instance's unified-login cookie: |
| 17 |
|
// sr-ht-ecore's one decoder, told to use core.ValidateOwner as its name rule. |
| 18 |
|
// |
| 19 |
|
// The decode itself is not ours and never was — decrypt without expiration, |
| 20 |
|
// unmarshal core-go's claims, strip one leading '~', treat every failure as |
| 21 |
|
// anonymity — and six services each keeping a copy of those four steps is how |
| 22 |
|
// one of them ends up missing the fifth. The fifth is the validator, and it is |
| 23 |
|
// the one thing here that stays spec.sr.ht's: a decoded name goes on to be |
| 24 |
|
// joined into a repository path under [spec.sr.ht] repos, and core.ValidateOwner |
| 25 |
|
// is the rule the rest of this service builds those paths against. Passing it |
| 26 |
|
// means there is one grammar rather than two that agree until one of them is |
| 27 |
|
// widened. |
| 28 |
|
// |
| 29 |
|
// Resolved once, at package level, because it is read on every request and |
| 30 |
|
// login.Option is a build step. |
| 31 |
|
var cookieDecode = []login.Option{login.WithValidator(validCookieName)} |
| 32 |
|
|
| 33 |
|
// validCookieName adapts core.ValidateOwner to login's predicate shape. An |
| 34 |
|
// error is "not a usable identity", which login turns into an anonymous viewer |
| 35 |
|
// — never an error page, because a name this service cannot use is the same |
| 36 |
|
// thing to a browser as no cookie at all. |
| 37 |
10 |
func validCookieName(name string) bool { return core.ValidateOwner(name) == nil } |
| 38 |
|
|
| 39 |
|
// Resolver turns a request into a Principal. It holds the instance owner |
| 40 |
|
// username — the one name a cookie has to match to carry authority — and, when |
| 41 |
|
// the instance is configured for it, the tokens.sr.ht plane every agent |
| 42 |
|
// credential is checked against. |
| 43 |
|
type Resolver struct { |
| 44 |
|
owner string |
| 45 |
|
|
| 46 |
|
// bearer and users are the agent plane, installed by WithInstancePlane. Both |
| 47 |
|
// are nil when the instance config has no [tokens.sr.ht] section, and a |
| 48 |
|
// resolver in that state authenticates no agent at all: since spec stopped |
| 49 |
|
// minting its own credential there is nothing else for a bearer token to be |
| 50 |
|
// checked against. It is still a legal resolver — the CLI paths that |
| 51 |
|
// authenticate nobody build one — but a bearer credential presented to it is |
| 52 |
|
// a hard ErrNoAgentPlane, never a shrug. |
| 53 |
|
bearer BearerValidator |
| 54 |
|
users UserLookup |
| 55 |
|
} |
| 56 |
|
|
| 57 |
|
// ResolverOption configures a Resolver at construction. Options rather than a |
| 58 |
|
// second constructor because the plane is genuinely absent in some processes: |
| 59 |
|
// `specsrht doc` builds a Service, resolves nobody, and has no use for an HTTP |
| 60 |
|
// client to tokens.sr.ht. |
| 61 |
|
type ResolverOption func(*Resolver) error |
| 62 |
|
|
| 63 |
|
// WithInstancePlane wires the tokens.sr.ht bearer plane in: v validates a |
| 64 |
|
// presented working token, users resolves its owner to a local row. |
| 65 |
|
// |
| 66 |
|
// Both are required together. A validator with no way to resolve an owner would |
| 67 |
|
// authenticate a token and then have nothing to say about who presented it, |
| 68 |
|
// which is the whole of what an agent credential is for here. |
| 69 |
26 |
func WithInstancePlane(v BearerValidator, users UserLookup) ResolverOption { |
| 70 |
26 |
return func(rs *Resolver) error { |
| 71 |
26 |
if v == nil { |
| 72 |
1 |
return fmt.Errorf("authn: nil BearerValidator") |
| 73 |
1 |
} |
| 74 |
25 |
if users == nil { |
| 75 |
1 |
return fmt.Errorf("authn: nil UserLookup") |
| 76 |
1 |
} |
| 77 |
24 |
rs.bearer = v |
| 78 |
24 |
rs.users = users |
| 79 |
24 |
return nil |
| 80 |
|
} |
| 81 |
|
} |
| 82 |
|
|
| 83 |
|
// NewResolver builds a Resolver for the instance owner named in |
| 84 |
|
// [sr.ht] owner-name. |
| 85 |
|
// |
| 86 |
|
// Pass WithInstancePlane to give it an agent plane. Without one it resolves |
| 87 |
|
// cookies and refuses every bearer credential with ErrNoAgentPlane; the daemon |
| 88 |
|
// therefore builds one with the plane and fails startup if it cannot, while the |
| 89 |
|
// CLI paths that authenticate nobody build one without. |
| 90 |
30 |
func NewResolver(owner string, opts ...ResolverOption) (*Resolver, error) { |
| 91 |
30 |
owner = strings.TrimPrefix(owner, "~") |
| 92 |
30 |
if err := core.ValidateOwner(owner); err != nil { |
| 93 |
2 |
return nil, fmt.Errorf("authn: instance owner: %w", err) |
| 94 |
2 |
} |
| 95 |
28 |
rs := &Resolver{owner: owner} |
| 96 |
28 |
for _, opt := range opts { |
| 97 |
26 |
if err := opt(rs); err != nil { |
| 98 |
2 |
return nil, err |
| 99 |
2 |
} |
| 100 |
|
} |
| 101 |
26 |
return rs, nil |
| 102 |
|
} |
| 103 |
|
|
| 104 |
|
// HasInstancePlane reports whether this resolver can authenticate an agent at |
| 105 |
|
// all. Startup logging and tests only; never an authorization input. |
| 106 |
1 |
func (rs *Resolver) HasInstancePlane() bool { return rs.bearer != nil } |
| 107 |
|
|
| 108 |
|
// Owner returns the instance owner username this resolver recognises. |
| 109 |
1 |
func (rs *Resolver) Owner() string { return rs.owner } |
| 110 |
|
|
| 111 |
|
// Resolve determines who is making a request. |
| 112 |
|
// |
| 113 |
|
// A bearer token wins over a cookie when both are present: an agent that went |
| 114 |
|
// to the trouble of presenting a credential is asking to be treated as an |
| 115 |
|
// agent, and letting a stale browser cookie promote it to the owner would hand |
| 116 |
|
// it the approved branch. The two credentials are checked in that order and |
| 117 |
|
// never merged. |
| 118 |
|
// |
| 119 |
|
// A presented bearer token goes to the tokens.sr.ht plane and nowhere else. |
| 120 |
|
// There is no second store behind it since spec stopped minting its own |
| 121 |
|
// credential, so every refusal that plane returns is final — see |
| 122 |
|
// resolveInstanceToken. |
| 123 |
|
// |
| 124 |
|
// The error contract is asymmetric on purpose: |
| 125 |
|
// |
| 126 |
|
// - No bearer token: never an error. The cookie decides between KindOwner and |
| 127 |
|
// KindAnonymous, and any cookie problem is anonymity, not failure. |
| 128 |
|
// - A bearer token that fails: an error. StatusFor separates the 401 case |
| 129 |
|
// (malformed, foreign, revoked) from the 403 case (a good token this |
| 130 |
|
// instance has nothing to grant) and the 503 case (tokens.sr.ht |
| 131 |
|
// unreachable, or no plane wired at all). |
| 132 |
|
// |
| 133 |
|
// The agent identity and session headers are read here but not required: they |
| 134 |
|
// are demanded at the write, by AgentWrite.Validate, which is the only place |
| 135 |
|
// the design requires them and the only place a missing one can do harm. |
| 136 |
38 |
func (rs *Resolver) Resolve(ctx context.Context, r *http.Request) (Principal, error) { |
| 137 |
38 |
if presented := BearerFromRequest(r); presented != "" { |
| 138 |
23 |
return rs.ResolveAgent(ctx, presented, |
| 139 |
23 |
r.Header.Get(HeaderAgent), r.Header.Get(HeaderAgentSession)) |
| 140 |
23 |
} |
| 141 |
|
|
| 142 |
15 |
username := login.UsernameFromRequest(r, cookieDecode...) |
| 143 |
15 |
if username == "" { |
| 144 |
11 |
return Anonymous(), nil |
| 145 |
11 |
} |
| 146 |
4 |
if username != rs.owner { |
| 147 |
1 |
// A real user of the instance who is not bigbes. Single-user means |
| 148 |
1 |
// there is nothing to grant them, so they read exactly as an anonymous |
| 149 |
1 |
// viewer does; the name is kept for the log line and the "you are |
| 150 |
1 |
// signed in as" affordance only. |
| 151 |
1 |
return Principal{Kind: KindAnonymous, CookieUser: username}, nil |
| 152 |
1 |
} |
| 153 |
3 |
return Principal{Kind: KindOwner, Owner: username, CookieUser: username}, nil |
| 154 |
|
} |
| 155 |
|
|
| 156 |
|
// ResolveAgent authenticates a presented agent credential, with the provenance |
| 157 |
|
// the caller collected alongside it, and is what Resolve calls once it has |
| 158 |
|
// pulled all three out of an HTTP request. |
| 159 |
|
// |
| 160 |
|
// It is exported because the push path is not an HTTP request: a `git push` |
| 161 |
|
// arrives over SSH and the credential reaches the daemon in a hook's |
| 162 |
|
// environment, not in an Authorization header. That path used to check the |
| 163 |
|
// agent_token table directly, which is precisely how it ended up unable to |
| 164 |
|
// accept an instance token while the HTTP surfaces could. One credential plane |
| 165 |
|
// deserves one implementation of "is this credential good?", so hooks calls this |
| 166 |
|
// and the two surfaces cannot drift. |
| 167 |
|
// |
| 168 |
|
// It never returns an anonymous principal on failure: a presented credential |
| 169 |
|
// that does not verify is an error, so the caller refuses at the door instead of |
| 170 |
|
// silently downgrading an agent to a reader. |
| 171 |
27 |
func (rs *Resolver) ResolveAgent(ctx context.Context, presented, agent, session string) (Principal, error) { |
| 172 |
27 |
if presented == "" { |
| 173 |
1 |
return Anonymous(), ErrNoToken |
| 174 |
1 |
} |
| 175 |
26 |
if rs.bearer == nil { |
| 176 |
2 |
// Not a bad credential: this process cannot check any credential. 503 |
| 177 |
2 |
// via StatusFor, and the operator's clue is in the message rather than |
| 178 |
2 |
// in an agent's incident report about a token that "stopped working". |
| 179 |
2 |
return Anonymous(), fmt.Errorf( |
| 180 |
2 |
"%w: spec.sr.ht authenticates agents through tokens.sr.ht, and this instance's "+ |
| 181 |
2 |
"config.ini has no [tokens.sr.ht] origin", ErrNoAgentPlane) |
| 182 |
2 |
} |
| 183 |
24 |
return rs.resolveInstanceToken(ctx, presented, |
| 184 |
24 |
strings.TrimSpace(agent), strings.TrimSpace(session)) |
| 185 |
|
} |
| 186 |
|
|
| 187 |
|
// Middleware attaches the resolved Principal to the request context, where |
| 188 |
|
// PrincipalFromContext reads it. |
| 189 |
|
// |
| 190 |
|
// It rejects only a failed bearer token — 401 for a bad credential, 403 for a |
| 191 |
|
// good one that this instance has nothing to grant, 503 for a backend that could |
| 192 |
|
// not answer, per StatusFor. Everything else, including every cookie problem, |
| 193 |
|
// flows through as anonymous: the read plane is anonymous-capable and must never |
| 194 |
|
// answer an error page on identity grounds. |
| 195 |
12 |
func (rs *Resolver) Middleware() func(http.Handler) http.Handler { |
| 196 |
12 |
return func(next http.Handler) http.Handler { |
| 197 |
12 |
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 198 |
12 |
p, err := rs.Resolve(r.Context(), r) |
| 199 |
12 |
if err != nil { |
| 200 |
9 |
status := StatusFor(err) |
| 201 |
9 |
if status >= 500 { |
| 202 |
4 |
// Fail closed and loudly. The alternative — degrading to |
| 203 |
4 |
// anonymous — would turn a Postgres blip or an unreachable |
| 204 |
4 |
// tokens.sr.ht into agents silently losing their write |
| 205 |
4 |
// access. |
| 206 |
4 |
slog.ErrorContext(r.Context(), "resolving an agent credential failed", |
| 207 |
4 |
"method", r.Method, "path", r.URL.Path, "status", status, |
| 208 |
4 |
scribe.Err(err)) |
| 209 |
4 |
} |
| 210 |
9 |
if status == http.StatusUnauthorized { |
| 211 |
4 |
// RFC 9110 requires the challenge on a 401, and the caller |
| 212 |
4 |
// here is always a machine holding a bearer token: naming the |
| 213 |
4 |
// scheme and the realm is what tells it which credential this |
| 214 |
4 |
// service was refusing. |
| 215 |
4 |
w.Header().Set("WWW-Authenticate", Challenge()) |
| 216 |
4 |
} |
| 217 |
9 |
http.Error(w, resolveFailureMessage(status), status) |
| 218 |
9 |
return |
| 219 |
|
} |
| 220 |
3 |
next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), p))) |
| 221 |
|
}) |
| 222 |
|
} |
| 223 |
|
} |
| 224 |
|
|
| 225 |
|
// resolveFailureMessage is what a refused caller is told. It is keyed on the |
| 226 |
|
// status and not on the error, so that nothing about which plane refused, whose |
| 227 |
|
// token it was, or whether a row exists leaks to a caller holding a credential |
| 228 |
|
// this service did not accept. |
| 229 |
9 |
func resolveFailureMessage(status int) string { |
| 230 |
9 |
switch status { |
| 231 |
4 |
case http.StatusUnauthorized: |
| 232 |
4 |
return "invalid agent token" |
| 233 |
1 |
case http.StatusForbidden: |
| 234 |
1 |
return "this token does not authorize requests to spec.sr.ht" |
| 235 |
4 |
default: |
| 236 |
4 |
return "authentication backend unavailable" |
| 237 |
|
} |
| 238 |
|
} |