oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Local sign-in is Keymail for an address whose domain runs Keymail and an
2// emailed single-use link for every other address, decided by the domain
3// alone (beginSignin). Optional Home sign-in resolves an explicitly
4// connected existing account; it never creates a person.
5package calendar
6
7import (
8 "amadan.net/oficina/estilo.git/chrome"
9 "amadan.net/oficina/home/connect"
10 "context"
11 "database/sql"
12 "fmt"
13 "log/slog"
14 "net/url"
15 "regexp"
16 "strings"
17 "sync"
18
19 rauth "amadan.net/rastrillo/rastrillo/auth"
20 "amadan.net/rastrillo/rastrillo/mail"
21)
22
23// authSchema aliases rauth.Schema so BootSchema (migrations.go) reads
24// as a list of subsystems rather than a mix of import aliases. Its
25// position there is load-bearing: auth's own backfill migration reads
26// the sessions table, so sessions.Schema must apply first.
27var authSchema = rauth.Schema
28
29// devInstanceKey is resolveInstanceKey's development-only fallback. It
30// is not a secret — it is a fixed, public string, exactly as dangerous
31// and exactly as acceptable as leaving the key empty would be, except
32// spelled out here rather than left implicit. It never seals anything
33// outside a throwaway development database.
34const devInstanceKey = "calendar-development-instance-key-not-a-secret"
35
36// resolveInstanceKey applies Config.InstanceKey's fail-closed rule:
37// non-empty always wins; empty is fine only in development; anywhere
38// else it is a startup refusal.
39func resolveInstanceKey(cfg Config) (string, error) {
40 key := strings.TrimSpace(cfg.InstanceKey)
41 if key != "" {
42 return key, nil
43 }
44 if cfg.Env == "development" {
45 return devInstanceKey, nil
46 }
47 return "", fmt.Errorf("calendar: Config.InstanceKey is empty and Env is %q, not %q — refusing to start with no seal for the sign-in link's pending blob; set $CALENDAR_INSTANCE_KEY", cfg.Env, "development")
48}
49
50// buildMailer applies Config.Mailer's fail-closed rule: an explicit
51// Mailer always wins; nil is fine only in development, where it falls
52// back to rastrillo/mail's logging sender.
53//
54// In development the chosen sender is always wrapped in a
55// *recordingMailer, whether or not Config.Mailer was already set: that
56// is what lets TestSignIn (testing.go) read the last mailed link back
57// without a mail server. The returned *recordingMailer is nil outside
58// development — it holds the plaintext of the last link sent in
59// memory, and a real deployment has no reason to carry that.
60func buildMailer(cfg Config, logger *slog.Logger) (mail.Sender, *recordingMailer, error) {
61 sender := cfg.Mailer
62 if sender == nil {
63 if cfg.Env != "development" {
64 // Both prefixes named, in the order main.go tries them:
65 // the platform's own CARLOS_SMTP_* is what `carlos email
66 // enable` writes, and naming only the app's prefix sent
67 // the first deploy of this app into a wake loop against
68 // an instance whose mail was configured correctly.
69 return nil, nil, fmt.Errorf("calendar: mail is not configured (set $CARLOS_SMTP_HOST and $CARLOS_SMTP_FROM with `carlos email enable`, or $CALENDAR_SMTP_HOST and $CALENDAR_SMTP_FROM to override) and Env is %q, not %q — refusing to start: a sign-in that cannot send its link is not sign-in, and a reminder that cannot be delivered is not a reminder", cfg.Env, "development")
70 }
71 sender = mail.Logged(logger)
72 }
73 if cfg.Env != "development" {
74 return sender, nil, nil
75 }
76 rec := &recordingMailer{next: sender}
77 return rec, rec, nil
78}
79
80// recordingMailer wraps a mail.Sender, remembering the most recently
81// sent body so TestSignIn can read the magic link back out of it. It
82// still forwards every Send, so the ordinary dev behaviour — the link
83// lands in the log, clearly marked — is unchanged; this only adds a
84// second, in-memory place to find it.
85type recordingMailer struct {
86 mu sync.Mutex
87 next mail.Sender
88 lastBody string
89}
90
91func (m *recordingMailer) Send(ctx context.Context, to, subject, body string) error {
92 m.mu.Lock()
93 m.lastBody = body
94 m.mu.Unlock()
95 return m.next.Send(ctx, to, subject, body)
96}
97
98func (m *recordingMailer) last() string {
99 m.mu.Lock()
100 defer m.mu.Unlock()
101 return m.lastBody
102}
103
104// magicLinkPattern finds the emailed link inside the sign-in mail body.
105// Anchored to this app's own callback path so it cannot mistake some
106// other URL the body might one day carry for the sign-in link itself.
107var magicLinkPattern = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`)
108
109// normalizeAddress puts an address into the one form the allowlist
110// both stores and compares: trimmed, lowercased. Shared by
111// resolveAllowlist and allowlist.allows so the two can never drift
112// into comparing differently-normalised forms of the same address.
113func normalizeAddress(address string) string {
114 return strings.ToLower(strings.TrimSpace(address))
115}
116
117// allowlist is Config.AllowedAddresses, parsed and normalised.
118//
119// addrs == nil means "admit every address" — resolveAllowlist's
120// development-only default, spelled out rather than left as an
121// accidental nil. addrs != nil means "admit nothing but exactly this
122// set". resolveAllowlist never returns a non-nil-but-empty map outside
123// development, since an empty parse there is a startup refusal.
124type allowlist struct {
125 addrs map[string]struct{}
126}
127
128func (a allowlist) allows(address string) bool {
129 if a.addrs == nil {
130 return true
131 }
132 _, ok := a.addrs[normalizeAddress(address)]
133 return ok
134}
135
136// resolveAllowlist applies Config.AllowedAddresses' fail-closed rule.
137//
138// Refusal, not "admit nobody" — a deliberate choice. An instance that
139// silently admitted nobody would boot looking entirely healthy:
140// /signin renders, POST /signin answers its ordinary 303 ?sent=1 on
141// every submission (gateBegin's whole point is that a refused
142// submission and a genuine one are byte-identical), and nothing would
143// surface the mistake short of somebody noticing that no link ever
144// arrives. A startup refusal is loud instead: the process does not
145// come up and whoever is deploying it sees that immediately. The
146// honest cost is that a misconfigured instance is down rather than
147// merely closed — accepted here because "down" gets fixed the same
148// day, while "quietly admits nobody" on a public hostname could sit
149// unnoticed indefinitely.
150func resolveAllowlist(cfg Config) (allowlist, error) {
151 addrs := make(map[string]struct{})
152 for _, addr := range strings.Split(cfg.AllowedAddresses, ",") {
153 addr = normalizeAddress(addr)
154 if addr == "" {
155 continue
156 }
157 // Every entry must look like an address, because the separator
158 // is the one thing nobody checks. Split on "," alone turns
159 // "a@x.com;b@y.com" — or a space- or newline-separated list —
160 // into ONE entry that is nobody's address, len(addrs) > 0, and
161 // a boot that succeeds into precisely the "quietly admits
162 // nobody" state the refusal below exists to prevent. The
163 // instance then looks entirely healthy and no link ever
164 // arrives, which is the failure this app has already had once.
165 //
166 // Refusing at boot rather than dropping the bad entry: dropping
167 // it would admit whoever parsed cleanly and silently lock out
168 // the rest, which is a worse shape of the same bug.
169 if !strings.Contains(addr, "@") {
170 return allowlist{}, fmt.Errorf(
171 "calendar: $CALENDAR_ALLOWED_ADDRESSES has an entry with no %q in it — the list is comma-separated, and a %q or a space is not a separator; refusing to start rather than admit nobody",
172 "@", ";")
173 }
174 addrs[addr] = struct{}{}
175 }
176 if len(addrs) > 0 {
177 return allowlist{addrs: addrs}, nil
178 }
179 if cfg.Env == "development" {
180 return allowlist{}, nil // addrs stays nil: admit everyone
181 }
182 return allowlist{}, fmt.Errorf("calendar: $CALENDAR_ALLOWED_ADDRESSES is empty and Env is %q, not %q — refusing to start with no allowlist configured; set it to this instance's address(es)", cfg.Env, "development")
183}
184
185// newAuthPlugin builds the one *rauth.Auth this process shares. One per
186// process, never one per request: the underlying flow carries its
187// rate-limiter budget on the value, so a plugin per request would get a
188// fresh empty budget every time — no rate limiting at all, silently.
189//
190// Authorize is list.allows as well as mailQueue. The two defend
191// different things: mailQueue stops the mail going out, Authorize
192// stops a session being minted for a non-permitted address, whether
193// Keymail verified it or a link was somehow minted for it (mailed
194// before the allowlist changed, say).
195//
196// h supplies the two pages auth asks the app for: the door a verified
197// but refused address is shown (handlers.refused) and the door an
198// emailed link opens on (handlers.renderConfirm).
199func newAuthPlugin(origin string, writer *sql.DB, instanceKey string, mailer mail.Sender, list allowlist, logger *slog.Logger, homeEnabled bool, h *handlers) (*rauth.Auth, error) {
200 signin, signedIn := "/signin", "/"
201 if homeEnabled {
202 signin, signedIn = "/signin/local", connect.LocalCompletePath
203 }
204 return rauth.New(rauth.Config{
205 DB: writer,
206 SigninPath: signin,
207 SignedInPath: signedIn,
208 Origin: origin,
209 InstanceKey: instanceKey,
210 Mailer: mailer,
211 Authorize: list.allows,
212 // A verified address the allowlist does not hold gets the door,
213 // with the refusal on it, in auth's own 403 (handlers.refused).
214 Refused: h.refused,
215 // GET /auth/verify spends nothing: it opens the door with one
216 // Sign in button that POSTs the link back, so a mail scanner's
217 // fetch cannot spend it (handlers.renderConfirm).
218 RenderConfirm: h.renderConfirm,
219 // No proof-of-work on the sign-in form. Calendar never had one:
220 // Begin's rate budgets and mailQueue's allowlist check are what
221 // stand between a stranger and the mailer, and the door is
222 // estilo's form, which renders no pow challenge. ProofOff keeps
223 // today's behaviour; wiring a pow.Guard is a decision of its own
224 // (schema, assets, a form that needs JavaScript).
225 ProofOff: true,
226 Subject: chrome.EmailSubject("Calendar"),
227 Logger: logger,
228 })
229}
230
231// linkPath extracts the request-target — path plus query, no scheme or
232// host — from an emailed magic link. It exists so the test harness does
233// not have to know the emailed link is absolute.
234func linkPath(link string) (string, error) {
235 u, err := url.Parse(link)
236 if err != nil {
237 return "", fmt.Errorf("calendar: parse magic link %q: %w", link, err)
238 }
239 return u.RequestURI(), nil
240}
241