oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "amadan.net/oficina/estilo.git/chrome"
5 "amadan.net/oficina/home/connect"
6 "database/sql"
7 "errors"
8 "fmt"
9 "log/slog"
10 "net/url"
11 "os"
12 "strings"
13
14 "amadan.net/rastrillo/rastrillo/auth"
15 "amadan.net/rastrillo/rastrillo/mail"
16)
17
18// AuthEnv is everything sign-in needs from the environment, already
19// validated. Building it is the only place these variable names appear.
20type AuthEnv struct {
21 Home *connect.Credentials
22 // Origin is the instance's external origin, scheme included. It is
23 // the base of every emailed link and the CSRF origin check, which
24 // makes a WRONG value the dangerous case rather than a missing one:
25 // a missing origin refuses at boot, while a wrong one boots happily
26 // and mints links pointing at a host nobody serves. Set it only
27 // after the route exists and answers.
28 Origin string
29
30 // InstanceKey seals auth's pending blob. auth.New refuses an empty
31 // one, but it refuses at the wiring call with a framework message;
32 // this refuses at boot naming the variable an operator has to set.
33 InstanceKey string
34
35 // Owners is the admission allowlist: the addresses that may hold a
36 // session on this instance.
37 Owners []string
38
39 // Mailer delivers the links.
40 Mailer mail.Sender
41}
42
43// loopback reports whether origin is a local development origin.
44//
45// This is the ONLY thing that relaxes the mail requirement, and it is
46// deliberately a fact rather than a switch. A deployed instance's origin
47// is its public https host, so it cannot take this branch; there is no
48// flag or variable that turns "development" on, so there is nothing to
49// set by accident in production. And the failure mode of lying about it
50// is loud rather than quiet — an instance claiming a loopback origin
51// mints links pointing at localhost, which nobody can click.
52// The host is compared after parsing, never by prefix. A prefix test on
53// "http://127.0.0.1" also accepts "http://127.0.0.1.attacker.example",
54// which is a real domain someone else can own — caught here by
55// TestLoopbackIsNotPrefixSpoofable, which failed on exactly that string
56// when this was written with strings.HasPrefix.
57func loopback(origin string) bool {
58 u, err := url.Parse(origin)
59 if err != nil || u.Scheme != "http" {
60 return false
61 }
62 switch u.Hostname() {
63 case "localhost", "127.0.0.1", "::1":
64 return true
65 }
66 return false
67}
68
69// LoadAuthEnv reads and validates sign-in configuration, refusing rather
70// than degrading.
71//
72// Every refusal here is a case where continuing would produce an app
73// that looks like it works. An instance with no mailer logs live
74// credentials to platform-held storage instead of emailing them; an
75// instance with an empty allowlist admits anyone who can receive mail at
76// any address. Both serve pages and answer /healthz.
77func LoadAuthEnv(logger *slog.Logger) (AuthEnv, error) {
78 env := AuthEnv{
79 Home: connect.CredentialsFromEnv("SHEETS"),
80 Origin: strings.TrimSpace(os.Getenv("SHEETS_ORIGIN")),
81 InstanceKey: strings.TrimSpace(os.Getenv("SHEETS_INSTANCE_KEY")),
82 Owners: splitOwners(os.Getenv("SHEETS_OWNERS")),
83 }
84
85 var missing []string
86 if env.Origin == "" {
87 missing = append(missing, "SHEETS_ORIGIN")
88 }
89 if env.InstanceKey == "" {
90 missing = append(missing, "SHEETS_INSTANCE_KEY")
91 }
92 if len(env.Owners) == 0 {
93 missing = append(missing, "SHEETS_OWNERS")
94 }
95 if len(missing) > 0 {
96 return AuthEnv{}, fmt.Errorf("sign-in is not configured: %s not set",
97 strings.Join(missing, ", "))
98 }
99
100 // mail.FromEnv falls back to a logging sender with a warning when
101 // SMTP is absent. A warning is not enough: the emailed link is a
102 // live credential and the log is platform-held storage we cannot
103 // grep or redact. Detect the fallback ourselves and refuse.
104 if os.Getenv("CARLOS_SMTP_HOST") == "" || os.Getenv("CARLOS_SMTP_FROM") == "" {
105 if !loopback(env.Origin) {
106 return AuthEnv{}, errors.New(
107 "sign-in is not configured: CARLOS_SMTP_HOST and CARLOS_SMTP_FROM not set, " +
108 "so magic links would be written to the log instead of emailed. " +
109 "Run `carlos email enable --app sheets`")
110 }
111 logger.Warn("sign-in: no SMTP configured and origin is loopback — " +
112 "magic links will be LOGGED, NOT SENT")
113 }
114 if _, err := connect.DecodeCredentials(env.Home, env.Origin); err != nil {
115 return AuthEnv{}, errHome
116 }
117 env.Mailer = mail.FromEnv("CARLOS", logger)
118
119 return env, nil
120}
121
122// splitOwners parses the allowlist, lowercasing and dropping blanks so a
123// trailing comma or stray space cannot admit or exclude anyone by
124// accident.
125func splitOwners(raw string) []string {
126 var out []string
127 for _, part := range strings.Split(raw, ",") {
128 if addr := strings.ToLower(strings.TrimSpace(part)); addr != "" {
129 out = append(out, addr)
130 }
131 }
132 return out
133}
134
135// NewAuth wires the sign-in flow. Exactly one per process: auth's
136// underlying flow carries rate-limiter state on the value, so one per
137// request would get a fresh empty budget every time and rate-limit
138// nothing, silently.
139//
140// app supplies the three things auth asks of the app at redemption:
141// the admission gate (App.Admits), the door a verified but refused
142// address is shown (App.refused), and the door an emailed link opens on
143// (App.renderConfirm). Admission is the App's rather than
144// Admits(env.Owners) because it is no longer a static list: a guest who
145// has been granted a sheet must be able to sign in, and only the
146// database knows who that is.
147func NewAuth(db *sql.DB, env AuthEnv, logger *slog.Logger, app *App) (*auth.Auth, error) {
148 if app == nil {
149 // A nil Authorize admits every verified address, which on a
150 // public-hostname instance means anyone who can receive mail.
151 // Refusing here beats defaulting to the allowlist, because a
152 // caller that forgot to pass one has a bug either way and this
153 // is the version that says so.
154 return nil, errors.New("sign-in: refusing to wire auth with no admission gate")
155 }
156 authorize := app.Admits
157 queue := newMailQueue(env.Mailer, authorize, nil, logger)
158 if a, ok := env.Mailer.(queueAttacher); ok {
159 a.attachQueue(queue.wait)
160 }
161 signedInPath := "/sheets"
162 if env.Home != nil {
163 signedInPath = connect.LocalCompletePath
164 }
165 plugin, err := auth.New(auth.Config{
166 DB: db,
167 Origin: env.Origin,
168 InstanceKey: env.InstanceKey,
169 // Begin runs for every address (handleBegin); the mail goes
170 // out after the response, and only to one authorize admits.
171 Mailer: queue,
172 Authorize: authorize,
173 // A verified address Authorize refuses gets the door, with the
174 // refusal on it, in auth's own 403 (App.refused).
175 Refused: app.refused,
176 // GET /auth/verify spends nothing: it opens the door with one
177 // Sign in button that POSTs the link back, so a mail scanner's
178 // fetch cannot spend it (App.renderConfirm).
179 RenderConfirm: app.renderConfirm,
180 // No proof-of-work on the sign-in form. Sheets never had one:
181 // handleBegin's rate budgets and mailQueue's admission check are
182 // what stand between a stranger and the mailer, and the door is
183 // estilo's form, which renders no pow challenge. ProofOff keeps
184 // today's behaviour; wiring a pow.Guard is a decision of its own
185 // (schema, assets, a form that needs JavaScript).
186 ProofOff: true,
187 SigninPath: "/signin",
188 // Where a fresh session lands. auth defaults this to "/",
189 // which is the anonymous entry page — so without this line
190 // every magic-link click arrives on a page that exists to
191 // offer sign-in, and has to be told it worked.
192 SignedInPath: signedInPath,
193 Subject: chrome.EmailSubject("Sheets"),
194 Logger: logger,
195 })
196 if err != nil {
197 queue.close()
198 return nil, err
199 }
200 signinQueuesMu.Lock()
201 signinQueues[plugin] = queue
202 signinQueuesMu.Unlock()
203 return plugin, nil
204}
205
206// admits builds the admission gate.
207//
208// A nil Authorize admits every verified address, which on a
209// public-hostname instance means anyone who can receive mail. The
210// allowlist is a slice-one stand-in for a membership table: it is
211// deliberately the smallest thing that is fail-closed, not the shape
212// sharing will eventually need.
213//
214// Comparison is on the lowercased address. That is not full
215// normalisation — addresses are case-sensitive before the @ per RFC
216// 5321, and no mainstream provider honours that — so this matches what
217// operators expect when they type an allowlist.
218func Admits(owners []string) func(string) bool {
219 allowed := make(map[string]struct{}, len(owners))
220 for _, o := range owners {
221 allowed[o] = struct{}{}
222 }
223 return func(address string) bool {
224 _, ok := allowed[strings.ToLower(strings.TrimSpace(address))]
225 return ok
226 }
227}
228