~bigbes/sr-ht-spec

ref: 42d4ee137ee6f4c70dd2ce4097c21606151f4a29 sr-ht-spec/authn/resolver.go -rw-r--r-- 4.2 KiB
42d4ee13 — Eugene Blikh feat(mcpsrv): spec_propose write tool (Phase 3) 26 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
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 the
// TokenStore agents are checked against.
type Resolver struct {
	owner string
	store TokenStore
}

// 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.
func NewResolver(owner string, store TokenStore) (*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")
	}
	return &Resolver{owner: owner, store: store}, 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.
//
// 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 != "" {
		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,
		}, 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, 503 for a
// store that could not answer. 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 {
				if IsAuthFailure(err) {
					http.Error(w, "invalid agent token", http.StatusUnauthorized)
					return
				}
				// Fail closed and loudly. The alternative — degrading to
				// anonymous — would turn a Postgres blip into agents silently
				// losing their write access.
				log.Printf("authn: resolving agent token: %v", err)
				http.Error(w, "authentication backend unavailable", http.StatusServiceUnavailable)
				return
			}
			next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), p)))
		})
	}
}